From 377d53d81ed1974546b0feb2df4620e29a390667 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Mon, 23 Mar 2026 00:21:24 -0400 Subject: [PATCH] Add flextable connector for export_tfl() Support passing flextable objects (and lists of them) directly to export_tfl(). Captions from set_caption() are extracted into writetfl's caption zone. Footer rows from footnote() and add_footer_lines() are extracted as plain text into writetfl's footnote zone, then removed from the table to avoid duplication. Rendering uses flextable's native gen_grob() which preserves all formatting (borders, merged cells, colours, themes). Non-PDF-safe fonts (e.g. Arial default) are automatically replaced with Helvetica. Pagination splits body rows across pages when a table is too tall, with a documented limitation that per-cell formatting is not preserved. Includes S3 method, converter, 11 helpers, 64 tests (100% coverage), vignette, README/main vignette updates, and design doc updates. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 12 +- DESCRIPTION | 1 + NAMESPACE | 1 + R/export_tfl.R | 19 +- R/flextable.R | 313 +++++++++++++++++++++ README.md | 27 +- design/ARCHITECTURE.md | 32 +++ design/DECISIONS.md | 45 +++ design/TESTING.md | 62 +++++ man/dot-clean_flextable.Rd | 19 ++ man/dot-extract_flextable_annotations.Rd | 20 ++ man/dot-flextable_content_height.Rd | 26 ++ man/dot-flextable_content_width.Rd | 20 ++ man/dot-flextable_grob_height.Rd | 20 ++ man/dot-flextable_set_pdf_font.Rd | 19 ++ man/dot-flextable_to_grob.Rd | 22 ++ man/dot-paginate_flextable.Rd | 23 ++ man/dot-rebuild_flextable_subset.Rd | 22 ++ man/export_tfl.Rd | 9 +- man/flextable_to_pagelist.Rd | 35 +++ tests/testthat/test-flextable.R | 331 +++++++++++++++++++++++ vignettes/v07-flextable.Rmd | 231 ++++++++++++++++ vignettes/writetfl.Rmd | 33 ++- 23 files changed, 1336 insertions(+), 6 deletions(-) create mode 100644 R/flextable.R create mode 100644 man/dot-clean_flextable.Rd create mode 100644 man/dot-extract_flextable_annotations.Rd create mode 100644 man/dot-flextable_content_height.Rd create mode 100644 man/dot-flextable_content_width.Rd create mode 100644 man/dot-flextable_grob_height.Rd create mode 100644 man/dot-flextable_set_pdf_font.Rd create mode 100644 man/dot-flextable_to_grob.Rd create mode 100644 man/dot-paginate_flextable.Rd create mode 100644 man/dot-rebuild_flextable_subset.Rd create mode 100644 man/flextable_to_pagelist.Rd create mode 100644 tests/testthat/test-flextable.R create mode 100644 vignettes/v07-flextable.Rmd diff --git a/CLAUDE.md b/CLAUDE.md index bbc9fd8..78707ac 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 | `formatters`, `gt`, `rtables`, `testthat (>= 3.0.0)`, `withr`, `knitr`, `rmarkdown`, `tibble` | +| Suggests | `flextable`, `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` | --- @@ -308,6 +308,12 @@ writetfl/ │ │ rtables_to_pagelist(), │ │ .extract_rtables_annotations(), │ │ .clean_rtables(), .rtables_to_grob() +│ ├── flextable.R ← export_tfl.flextable(), +│ │ flextable_to_pagelist(), +│ │ .extract_flextable_annotations(), +│ │ .clean_flextable(), +│ │ .flextable_to_grob(), +│ │ .paginate_flextable() │ ├── reexports.R ← re-exports unit, gpar from grid │ ├── table_columns.R ← resolve_col_specs(), compute_col_widths(), │ │ paginate_cols() @@ -333,6 +339,7 @@ writetfl/ │ ├── test-ggtibble.R │ ├── test-gt.R │ ├── test-rtables.R +│ ├── test-flextable.R │ └── test-integration.R ├── vignettes/ │ ├── writetfl.Rmd @@ -341,7 +348,8 @@ writetfl/ │ ├── v03-tfl_table_styling.Rmd │ ├── v04-troubleshooting.Rmd │ ├── v05-gt_tables.Rmd -│ └── v06-rtables.Rmd +│ ├── v06-rtables.Rmd +│ └── v07-flextable.Rmd └── design/ ├── DESIGN.md ├── ARCHITECTURE.md diff --git a/DESCRIPTION b/DESCRIPTION index a48820a..48bf04a 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -26,6 +26,7 @@ Imports: glue, rlang Suggests: + flextable, formatters, ggtibble, gt, diff --git a/NAMESPACE b/NAMESPACE index 3338ba7..f7ff157 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -3,6 +3,7 @@ S3method(drawDetails,tfl_table_grob) S3method(export_tfl,VTableTree) S3method(export_tfl,default) +S3method(export_tfl,flextable) S3method(export_tfl,ggtibble) S3method(export_tfl,gt_tbl) S3method(export_tfl,list) diff --git a/R/export_tfl.R b/R/export_tfl.R index 123aa9f..d872a5e 100644 --- a/R/export_tfl.R +++ b/R/export_tfl.R @@ -46,6 +46,13 @@ #' `textGrob`. Pagination uses rtables' built-in `paginate_table()`. #' A list of `VTableTree` objects produces one page (or more, with #' pagination) per table. +#' +#' When `x` is a `flextable` object (from the \pkg{flextable} package), +#' the caption (from [flextable::set_caption()]) is extracted as the +#' caption, and footer rows (from [flextable::footnote()] or +#' [flextable::add_footer_lines()]) are extracted as the footnote. The +#' table is rendered via [flextable::gen_grob()]. A list of `flextable` +#' 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. @@ -173,7 +180,17 @@ export_tfl.list <- function( pages <- unlist(lapply(x, rtables_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 flextable objects + all_flextable <- length(x) > 0L && + all(vapply(x, inherits, logical(1L), "flextable")) + if (all_flextable) { + rlang::check_installed("flextable", + reason = "to export flextable tables") + pages <- unlist(lapply(x, flextable_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/flextable.R b/R/flextable.R new file mode 100644 index 0000000..c0b0d22 --- /dev/null +++ b/R/flextable.R @@ -0,0 +1,313 @@ +# flextable.R — S3 method and conversion for flextable objects +# +# Functions: +# export_tfl.flextable() — S3 method dispatched by export_tfl() +# flextable_to_pagelist() — convert a flextable to a list of page specs +# .extract_flextable_annotations() — extract caption and footer-row footnotes +# .clean_flextable() — remove footer rows from flextable +# .flextable_content_height() — compute available content height +# .flextable_content_width() — compute available content width +# .flextable_grob_height() — measure a flextableGrob height +# .flextable_to_grob() — render a flextable to a grob via gen_grob() +# .paginate_flextable() — greedy row pagination +# .rebuild_flextable_subset() — create a sub-flextable from row indices + +#' @export +export_tfl.flextable <- function( + x, + file = NULL, + pg_width = 11, + pg_height = 8.5, + page_num = "Page {i} of {n}", + preview = FALSE, + ... +) { + rlang::check_installed("flextable", reason = "to export flextable tables") + dots <- list(...) + .validate_export_args(page_num, preview, file) + pages <- flextable_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 flextable object to a list of page specification lists +#' +#' Extracts caption and footer-row footnotes from the flextable, removes the +#' footer rows to avoid duplication, then renders via [flextable::gen_grob()]. +#' +#' When the rendered table exceeds the available content height, rows are +#' split across multiple pages using greedy pagination. +#' +#' @param ft_obj A `flextable` 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 +flextable_to_pagelist <- function(ft_obj, pg_width = 11, pg_height = 8.5, + dots = list(), page_num = "Page {i} of {n}") { + annot <- .extract_flextable_annotations(ft_obj) + cleaned <- .clean_flextable(ft_obj) + + # Measure available content area + content_h <- .flextable_content_height(pg_width, pg_height, dots, page_num, + annot) + content_w <- .flextable_content_width(pg_width, dots) + + # Convert to grob and measure height + grob <- .flextable_to_grob(cleaned, content_w) + grob_h <- .flextable_grob_height(grob) + + # If the table fits on a single page, return immediately + if (grob_h <= content_h) { + 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 + return(list(page_spec)) + } + + # Paginate: split rows across pages + ft_pages <- .paginate_flextable(cleaned, content_h, content_w) + + lapply(ft_pages, function(ft_page) { + page_grob <- .flextable_to_grob(ft_page, content_w) + page_spec <- list(content = page_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 flextable object +#' +#' Extracts caption from `set_caption()` and footnote text from footer rows +#' (added via `footnote()` or `add_footer_lines()`). +#' +#' @param ft_obj A `flextable` object. +#' @return A list with `$caption` (character or NULL) and `$footnote` +#' (character or NULL). +#' @keywords internal +.extract_flextable_annotations <- function(ft_obj) { + # Caption + cap_val <- ft_obj$caption$value + caption <- if (!is.null(cap_val) && nzchar(cap_val)) cap_val + + # Footnote: extract text from footer rows + n_footer <- flextable::nrow_part(ft_obj, "footer") + footnote <- NULL + if (n_footer > 0L) { + content_data <- ft_obj$footer$content$data + fn_lines <- vapply(seq_len(n_footer), function(i) { + # Each row's first column contains the text (footer lines span all cols) + chunks <- content_data[[i, 1L]] + paste(chunks$txt, collapse = "") + }, character(1L)) + fn_lines <- fn_lines[nzchar(fn_lines)] + if (length(fn_lines) > 0L) { + footnote <- paste(fn_lines, collapse = "\n") + } + } + + list(caption = caption, footnote = footnote) +} + +#' Remove footer rows from a flextable object +#' +#' Strips footer rows so that `gen_grob()` renders only the header and body. +#' Footer text has already been extracted into writetfl's footnote zone. +#' +#' @param ft_obj A `flextable` object. +#' @return A cleaned `flextable` object. +#' @keywords internal +.clean_flextable <- function(ft_obj) { + n_footer <- flextable::nrow_part(ft_obj, "footer") + if (n_footer > 0L) { + ft_obj <- flextable::delete_rows(ft_obj, i = seq_len(n_footer), + part = "footer") + } + ft_obj +} + +#' Compute available content height for flextable 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_flextable_annotations()]. +#' @return Numeric scalar: available content height in inches. +#' @keywords internal +.flextable_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 +.flextable_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] +} + +#' Measure a flextableGrob's height +#' +#' flextableGrob does not support standard `grobHeight()` measurement. +#' Instead, the total height is available from the grob's `ftpar$heights` +#' attribute, which contains per-row heights in inches. +#' +#' @param grob A `flextableGrob` from [flextable::gen_grob()]. +#' @return Numeric scalar: grob height in inches. +#' @keywords internal +.flextable_grob_height <- function(grob) { + sum(grob$ftpar$heights) +} + +#' Render a flextable to a grob via gen_grob() +#' +#' Sets column widths proportionally to fit the available content width, +#' ensures a PDF-compatible font is used, then calls +#' [flextable::gen_grob()] with `fit = "width"` and top-left justification. +#' +#' @param ft_obj A `flextable` object. +#' @param content_w Available content width in inches. +#' @return A `flextableGrob` (inherits from `gTree`). +#' @keywords internal +.flextable_to_grob <- function(ft_obj, content_w) { + # Scale column widths to fit available content width + orig_widths <- ft_obj$body$colwidths + total_w <- sum(orig_widths) + if (total_w > 0) { + scale_factor <- content_w / total_w + ft_obj <- flextable::width(ft_obj, width = orig_widths * scale_factor) + } + + # Ensure a PDF-compatible font (flextable defaults to "Arial" which + # does not work on the standard PDF device) + ft_obj <- .flextable_set_pdf_font(ft_obj) + + flextable::gen_grob(ft_obj, fit = "width", just = c("left", "top")) +} + +#' Set PDF-compatible font on a flextable +#' +#' Replaces any non-standard font families with `"Helvetica"` (a standard +#' PDF base font) to avoid "invalid font type" errors on the PDF device. +#' +#' @param ft_obj A `flextable` object. +#' @return The modified `flextable` object. +#' @keywords internal +.flextable_set_pdf_font <- function(ft_obj) { + # Standard PDF base fonts + pdf_fonts <- c("Helvetica", "Times", "Courier", "sans", "serif", "mono", + "Helvetica-Bold", "Helvetica-Oblique", + "Times-Roman", "Times-Bold", "Courier-Bold") + defaults <- flextable::get_flextable_defaults() + default_font <- defaults[["font.family"]] + if (!is.null(default_font) && !(default_font %in% pdf_fonts)) { + ft_obj <- flextable::font(ft_obj, fontname = "Helvetica", part = "all") + } + ft_obj +} + +#' Greedy row pagination for flextable +#' +#' Incrementally adds body rows, measures the sub-table height, and splits +#' when a page would overflow. +#' +#' @param ft_obj A cleaned `flextable` (no footer rows). +#' @param content_h Available content height in inches. +#' @param content_w Available content width in inches. +#' @return A list of `flextable` objects (one per page). +#' @keywords internal +.paginate_flextable <- function(ft_obj, content_h, content_w) { + n_body <- flextable::nrow_part(ft_obj, "body") + + pages <- list() + current_rows <- integer(0L) + + for (row_idx in seq_len(n_body)) { + candidate_rows <- c(current_rows, row_idx) + sub_ft <- .rebuild_flextable_subset(ft_obj, candidate_rows) + sub_grob <- .flextable_to_grob(sub_ft, content_w) + h <- .flextable_grob_height(sub_grob) + + if (h > content_h && length(current_rows) > 0L) { + # Current row doesn't fit — finalize current page + pages <- c(pages, list(.rebuild_flextable_subset(ft_obj, current_rows))) + current_rows <- row_idx + } else { + current_rows <- candidate_rows + } + } + if (length(current_rows) > 0L) { + pages <- c(pages, list(.rebuild_flextable_subset(ft_obj, current_rows))) + } + + pages +} + +#' Rebuild a flextable from a row index subset +#' +#' Creates a new flextable from the subset of body rows, preserving the +#' header structure and column widths. Per-cell formatting applied via +#' `color()`, `bg()`, `bold()`, etc. is NOT preserved. +#' +#' @param ft_obj A `flextable` object (already cleaned of footer rows). +#' @param row_indices Integer vector of body row indices to keep. +#' @return A new `flextable` object containing only the specified rows. +#' @keywords internal +.rebuild_flextable_subset <- function(ft_obj, row_indices) { + data <- ft_obj$body$dataset + sub_data <- data[row_indices, , drop = FALSE] + + # Create new flextable from subset data + sub_ft <- flextable::flextable(sub_data) + + # Copy header structure + sub_ft$header <- ft_obj$header + + # Copy column widths + sub_ft$body$colwidths <- ft_obj$body$colwidths + sub_ft$header$colwidths <- ft_obj$header$colwidths + + sub_ft +} diff --git a/README.md b/README.md index b02ed58..c3fb5c0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **Standardized table, figure, and listing output for clinical trial reporting.** `writetfl` produces multi-page PDF files from `ggplot2` figures, data-frame -tables, `gt` tables, `rtables` tables, and other grid content with the precise, +tables, `gt` tables, `rtables` tables, `flextable` 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, @@ -353,3 +353,28 @@ 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. +### flextable tables + +Pass a `flextable` object directly to `export_tfl()`. Captions (from +`set_caption()`) are extracted into writetfl's caption zone. Footer rows +(from `footnote()` or `add_footer_lines()`) are extracted into writetfl's +footnote zone. The table is rendered via `gen_grob()` with all formatting +preserved — borders, merged cells, colours, themes, and more. + +```r +library(flextable) + +ft <- flextable(head(iris, 10)) |> + set_caption("Iris Measurements") |> + add_footer_lines("Source: Anderson (1935).") + +export_tfl(ft, file = "flextable_table.pdf", + header_left = "Study Report", + header_rule = TRUE, + footer_rule = TRUE +) +``` + +A list of `flextable` objects produces a multi-page PDF. See +`vignette("v07-flextable")` for full details. + diff --git a/design/ARCHITECTURE.md b/design/ARCHITECTURE.md index ef4719a..b6c9459 100644 --- a/design/ARCHITECTURE.md +++ b/design/ARCHITECTURE.md @@ -219,6 +219,37 @@ export_tfl(x = list_of_VTableTree, ...) [exported] ├── detects all elements are VTableTree ├── lapply(x, rtables_to_pagelist, ...) |> unlist(recursive = FALSE) └── .export_tfl_pages(...) + +export_tfl(x = flextable_obj, ...) [exported] + └── export_tfl.flextable() — flextable.R + └── flextable_to_pagelist(x, ...) — flextable.R + ├── .extract_flextable_annotations(x) — flextable.R + │ caption from ft$caption$value + │ footnote from footer row chunks + ├── .clean_flextable(x) — flextable.R + │ deletes footer rows + ├── .flextable_content_height(...) — flextable.R + │ reuses compute_table_content_area() + ├── .flextable_content_width(...) — flextable.R + ├── .flextable_to_grob(cleaned, w) — flextable.R + │ .flextable_set_pdf_font(ft) + │ flextable::gen_grob(ft, fit="width") + ├── .flextable_grob_height(grob) — flextable.R + │ sum(grob$ftpar$heights) + ├── if too tall: + │ .paginate_flextable(cleaned, h, w) — flextable.R + │ greedy row-based pagination + │ .rebuild_flextable_subset(ft, rows) + │ → list of sub-flextable objects + └── for each page: + .flextable_to_grob(page, w) + → page spec with $content, $caption, $footnote + +export_tfl(x = list_of_flextable, ...) [exported] + └── export_tfl.list() + ├── detects all elements are flextable + ├── lapply(x, flextable_to_pagelist, ...) |> unlist(recursive = FALSE) + └── .export_tfl_pages(...) ``` --- @@ -240,6 +271,7 @@ export_tfl(x = list_of_VTableTree, ...) [exported] | `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/flextable.R` | `export_tfl.flextable()`, `flextable_to_pagelist()`, `.extract_flextable_annotations()`, `.clean_flextable()`, `.flextable_content_height()`, `.flextable_content_width()`, `.flextable_grob_height()`, `.flextable_to_grob()`, `.flextable_set_pdf_font()`, `.paginate_flextable()`, `.rebuild_flextable_subset()` | | `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 c9051ab..f6b4f34 100644 --- a/design/DECISIONS.md +++ b/design/DECISIONS.md @@ -573,6 +573,51 @@ of each rtables-related method. --- +## D-34: flextable connector — gen_grob() rendering + +**Decision:** Convert flextable objects to grid grobs via +`flextable::gen_grob()` with `fit = "width"`. Extract caption from +`set_caption()` and footer-row text from `footnote()` / +`add_footer_lines()` into writetfl's annotation zones. + +**Alternatives considered:** +- Render flextable to image and embed — loses scalability and editability. +- Manual cell-by-cell construction — flextable already provides `gen_grob()` + which handles all formatting. + +**Chosen because:** `gen_grob()` is flextable's native grid renderer. It +produces a `flextableGrob` (inherits from `gTree`) that preserves all +formatting: borders, colours, backgrounds, merged cells, text styles, +themes, and cell content. This is the simplest connector — no complex +internal subsetting like gt, no toString conversion like rtables. + +**Annotation extraction:** Caption from `ft$caption$value`. Footnotes +from footer rows (`ft$footer$content$data` matrix, concatenating chunk +`txt` values per row). Footer rows are removed via +`flextable::delete_rows()` to avoid duplication. + +**Font handling:** Flextable defaults to "Arial" which is not available +on the standard PDF device. `.flextable_set_pdf_font()` replaces +non-standard fonts with "Helvetica" (a PDF base font) before +`gen_grob()` is called. + +**Pagination:** Greedy row-based pagination similar to gt. Body rows +are incrementally added and sub-tables measured via `gen_grob()`. Height +is measured from `grob$ftpar$heights` (sum of per-row heights) since +`flextableGrob` does not support standard `grobHeight()`. + +**Pagination limitation:** Per-cell formatting (from `color()`, `bg()`, +`bold()`, etc.) is NOT preserved when subsetting rows for pagination. +Flextable stores styles in internal structures that cannot be safely +subset. This is documented and acceptable since most clinical tables +fit on a single landscape page. + +**flextable is a soft dependency** (Suggests only). +`rlang::check_installed()` is called at the top of each +flextable-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 2f7e852..011f3ac 100644 --- a/design/TESTING.md +++ b/design/TESTING.md @@ -29,6 +29,7 @@ One test file per source file — `tests/testthat/test-.R` covers | `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-flextable.R` | `.extract_flextable_annotations()`, `.clean_flextable()`, `.flextable_to_grob()`, `.flextable_grob_height()`, `.flextable_content_height()`, `.flextable_content_width()`, `flextable_to_pagelist()`, `.rebuild_flextable_subset()`, `.paginate_flextable()`, `export_tfl.flextable()`, `export_tfl.list()` with flextable objects, S3 dispatch | | `test-integration.R` | Multi-file end-to-end smoke tests spanning the full pipeline | --- @@ -368,6 +369,67 @@ test_that("rtables_to_pagelist accepts font parameters via dots", ...) --- +## `test-flextable.R` — flextable connector + +All tests wrapped in `skip_if_not_installed("flextable")`. + +**Annotation extraction:** +- caption extracted from `set_caption()` +- NULL caption when none set +- NULL caption when empty string +- footnotes extracted from `add_footer_lines()` +- footnotes extracted from `footnote()` +- NULL footnote when no footer rows +- caption and footnote extracted together + +**Cleaning:** +- footer rows removed by `.clean_flextable()` +- `.clean_flextable()` is no-op when no footer rows + +**Grob conversion:** +- `.flextable_grob_height()` returns positive numeric +- `.flextable_to_grob()` returns a `flextableGrob` +- `.flextable_to_grob()` scales to content width + +**Content dimensions:** +- `.flextable_content_height()` returns positive numeric +- `.flextable_content_height()` respects custom dots +- `.flextable_content_height()` respects custom margins via dots +- `.flextable_content_width()` returns positive numeric +- `.flextable_content_width()` respects custom margins + +**Conversion:** +- `flextable_to_pagelist()` returns page spec with content and annotations +- `flextable_to_pagelist()` works without annotations + +**Rebuild subset:** +- `.rebuild_flextable_subset()` creates valid sub-flextable +- preserves header structure +- preserves column widths + +**Pagination:** +- `.paginate_flextable()` splits tall table into multiple pages + +**End-to-end:** +- `export_tfl()` writes PDF from flextable +- preview mode works +- preview with specific pages +- list of flextable objects → multi-page PDF +- list preview mode +- tall flextable paginates + +**S3 dispatch:** +- method registered for flextable class +- `export_tfl()` dispatches to flextable method + +**Preserved features:** +- borders preserved in grob output +- merged cells preserved +- theme preserved +- page layout elements work with flextable + +--- + ## `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_flextable.Rd b/man/dot-clean_flextable.Rd new file mode 100644 index 0000000..2a6a218 --- /dev/null +++ b/man/dot-clean_flextable.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/flextable.R +\name{.clean_flextable} +\alias{.clean_flextable} +\title{Remove footer rows from a flextable object} +\usage{ +.clean_flextable(ft_obj) +} +\arguments{ +\item{ft_obj}{A \code{flextable} object.} +} +\value{ +A cleaned \code{flextable} object. +} +\description{ +Strips footer rows so that \code{gen_grob()} renders only the header and body. +Footer text has already been extracted into writetfl's footnote zone. +} +\keyword{internal} diff --git a/man/dot-extract_flextable_annotations.Rd b/man/dot-extract_flextable_annotations.Rd new file mode 100644 index 0000000..c62d60d --- /dev/null +++ b/man/dot-extract_flextable_annotations.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/flextable.R +\name{.extract_flextable_annotations} +\alias{.extract_flextable_annotations} +\title{Extract annotations from a flextable object} +\usage{ +.extract_flextable_annotations(ft_obj) +} +\arguments{ +\item{ft_obj}{A \code{flextable} object.} +} +\value{ +A list with \verb{$caption} (character or NULL) and \verb{$footnote} +(character or NULL). +} +\description{ +Extracts caption from \code{set_caption()} and footnote text from footer rows +(added via \code{footnote()} or \code{add_footer_lines()}). +} +\keyword{internal} diff --git a/man/dot-flextable_content_height.Rd b/man/dot-flextable_content_height.Rd new file mode 100644 index 0000000..859605e --- /dev/null +++ b/man/dot-flextable_content_height.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/flextable.R +\name{.flextable_content_height} +\alias{.flextable_content_height} +\title{Compute available content height for flextable pagination} +\usage{ +.flextable_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_flextable_annotations]{.extract_flextable_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-flextable_content_width.Rd b/man/dot-flextable_content_width.Rd new file mode 100644 index 0000000..231dec8 --- /dev/null +++ b/man/dot-flextable_content_width.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/flextable.R +\name{.flextable_content_width} +\alias{.flextable_content_width} +\title{Compute available content width} +\usage{ +.flextable_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-flextable_grob_height.Rd b/man/dot-flextable_grob_height.Rd new file mode 100644 index 0000000..4ce36fa --- /dev/null +++ b/man/dot-flextable_grob_height.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/flextable.R +\name{.flextable_grob_height} +\alias{.flextable_grob_height} +\title{Measure a flextableGrob's height} +\usage{ +.flextable_grob_height(grob) +} +\arguments{ +\item{grob}{A \code{flextableGrob} from \code{\link[flextable:gen_grob]{flextable::gen_grob()}}.} +} +\value{ +Numeric scalar: grob height in inches. +} +\description{ +flextableGrob does not support standard \code{grobHeight()} measurement. +Instead, the total height is available from the grob's \code{ftpar$heights} +attribute, which contains per-row heights in inches. +} +\keyword{internal} diff --git a/man/dot-flextable_set_pdf_font.Rd b/man/dot-flextable_set_pdf_font.Rd new file mode 100644 index 0000000..a9397bc --- /dev/null +++ b/man/dot-flextable_set_pdf_font.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/flextable.R +\name{.flextable_set_pdf_font} +\alias{.flextable_set_pdf_font} +\title{Set PDF-compatible font on a flextable} +\usage{ +.flextable_set_pdf_font(ft_obj) +} +\arguments{ +\item{ft_obj}{A \code{flextable} object.} +} +\value{ +The modified \code{flextable} object. +} +\description{ +Replaces any non-standard font families with \code{"Helvetica"} (a standard +PDF base font) to avoid "invalid font type" errors on the PDF device. +} +\keyword{internal} diff --git a/man/dot-flextable_to_grob.Rd b/man/dot-flextable_to_grob.Rd new file mode 100644 index 0000000..9afb49a --- /dev/null +++ b/man/dot-flextable_to_grob.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/flextable.R +\name{.flextable_to_grob} +\alias{.flextable_to_grob} +\title{Render a flextable to a grob via gen_grob()} +\usage{ +.flextable_to_grob(ft_obj, content_w) +} +\arguments{ +\item{ft_obj}{A \code{flextable} object.} + +\item{content_w}{Available content width in inches.} +} +\value{ +A \code{flextableGrob} (inherits from \code{gTree}). +} +\description{ +Sets column widths proportionally to fit the available content width, +ensures a PDF-compatible font is used, then calls +\code{\link[flextable:gen_grob]{flextable::gen_grob()}} with \code{fit = "width"} and top-left justification. +} +\keyword{internal} diff --git a/man/dot-paginate_flextable.Rd b/man/dot-paginate_flextable.Rd new file mode 100644 index 0000000..cd32584 --- /dev/null +++ b/man/dot-paginate_flextable.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/flextable.R +\name{.paginate_flextable} +\alias{.paginate_flextable} +\title{Greedy row pagination for flextable} +\usage{ +.paginate_flextable(ft_obj, content_h, content_w) +} +\arguments{ +\item{ft_obj}{A cleaned \code{flextable} (no footer rows).} + +\item{content_h}{Available content height in inches.} + +\item{content_w}{Available content width in inches.} +} +\value{ +A list of \code{flextable} objects (one per page). +} +\description{ +Incrementally adds body rows, measures the sub-table height, and splits +when a page would overflow. +} +\keyword{internal} diff --git a/man/dot-rebuild_flextable_subset.Rd b/man/dot-rebuild_flextable_subset.Rd new file mode 100644 index 0000000..1588002 --- /dev/null +++ b/man/dot-rebuild_flextable_subset.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/flextable.R +\name{.rebuild_flextable_subset} +\alias{.rebuild_flextable_subset} +\title{Rebuild a flextable from a row index subset} +\usage{ +.rebuild_flextable_subset(ft_obj, row_indices) +} +\arguments{ +\item{ft_obj}{A \code{flextable} object (already cleaned of footer rows).} + +\item{row_indices}{Integer vector of body row indices to keep.} +} +\value{ +A new \code{flextable} object containing only the specified rows. +} +\description{ +Creates a new flextable from the subset of body rows, preserving the +header structure and column widths. Per-cell formatting applied via +\code{color()}, \code{bg()}, \code{bold()}, etc. is NOT preserved. +} +\keyword{internal} diff --git a/man/export_tfl.Rd b/man/export_tfl.Rd index 39258c4..5baa8d0 100644 --- a/man/export_tfl.Rd +++ b/man/export_tfl.Rd @@ -51,7 +51,14 @@ 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.} +pagination) per table. + +When \code{x} is a \code{flextable} object (from the \pkg{flextable} package), +the caption (from \code{\link[flextable:set_caption]{flextable::set_caption()}}) is extracted as the +caption, and footer rows (from \code{\link[flextable:footnote]{flextable::footnote()}} or +\code{\link[flextable:add_footer_lines]{flextable::add_footer_lines()}}) are extracted as the footnote. The +table is rendered via \code{\link[flextable:gen_grob]{flextable::gen_grob()}}. A list of \code{flextable} +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/flextable_to_pagelist.Rd b/man/flextable_to_pagelist.Rd new file mode 100644 index 0000000..a80d4d5 --- /dev/null +++ b/man/flextable_to_pagelist.Rd @@ -0,0 +1,35 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/flextable.R +\name{flextable_to_pagelist} +\alias{flextable_to_pagelist} +\title{Convert a flextable object to a list of page specification lists} +\usage{ +flextable_to_pagelist( + ft_obj, + pg_width = 11, + pg_height = 8.5, + dots = list(), + page_num = "Page {i} of {n}" +) +} +\arguments{ +\item{ft_obj}{A \code{flextable} 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 caption and footer-row footnotes from the flextable, removes the +footer rows to avoid duplication, then renders via \code{\link[flextable:gen_grob]{flextable::gen_grob()}}. +} +\details{ +When the rendered table exceeds the available content height, rows are +split across multiple pages using greedy pagination. +} +\keyword{internal} diff --git a/tests/testthat/test-flextable.R b/tests/testthat/test-flextable.R new file mode 100644 index 0000000..9cad8d5 --- /dev/null +++ b/tests/testthat/test-flextable.R @@ -0,0 +1,331 @@ +skip_if_not_installed("flextable") + +# Helper: build a simple flextable +make_ft <- function(caption = NULL, footer_lines = NULL, footnotes = NULL) { + ft <- flextable::flextable(head(mtcars[, 1:4], 5)) + if (!is.null(caption)) { + ft <- flextable::set_caption(ft, caption) + } + if (!is.null(footer_lines)) { + ft <- flextable::add_footer_lines(ft, footer_lines) + } + if (!is.null(footnotes)) { + for (i in seq_along(footnotes)) { + ft <- flextable::footnote( + ft, i = i, j = 1, part = "body", + value = flextable::as_paragraph(footnotes[[i]]), + ref_symbols = as.character(i) + ) + } + } + ft +} + +# .extract_flextable_annotations() ---------------------------------------- + +test_that("caption extracted from set_caption()", { + ft <- make_ft(caption = "My Caption") + annot <- writetfl:::.extract_flextable_annotations(ft) + expect_equal(annot$caption, "My Caption") +}) + +test_that("NULL caption when none set", { + ft <- make_ft() + annot <- writetfl:::.extract_flextable_annotations(ft) + expect_null(annot$caption) +}) + +test_that("NULL caption when empty string", { + ft <- flextable::flextable(head(mtcars, 3)) + ft <- flextable::set_caption(ft, "") + annot <- writetfl:::.extract_flextable_annotations(ft) + expect_null(annot$caption) +}) + +test_that("footnotes extracted from add_footer_lines()", { + ft <- make_ft(footer_lines = c("Footer 1", "Footer 2")) + annot <- writetfl:::.extract_flextable_annotations(ft) + expect_equal(annot$footnote, "Footer 1\nFooter 2") +}) + +test_that("footnotes extracted from footnote()", { + ft <- make_ft(footnotes = c("Note A", "Note B")) + annot <- writetfl:::.extract_flextable_annotations(ft) + expect_true(grepl("Note A", annot$footnote)) + expect_true(grepl("Note B", annot$footnote)) +}) + +test_that("NULL footnote when no footer rows", { + ft <- make_ft() + annot <- writetfl:::.extract_flextable_annotations(ft) + expect_null(annot$footnote) +}) + +test_that("caption and footnote extracted together", { + ft <- make_ft(caption = "Title", footer_lines = "Source: test data.") + annot <- writetfl:::.extract_flextable_annotations(ft) + expect_equal(annot$caption, "Title") + expect_equal(annot$footnote, "Source: test data.") +}) + +# .clean_flextable() ------------------------------------------------------ + +test_that("footer rows removed by .clean_flextable()", { + ft <- make_ft(footer_lines = c("Footer 1", "Footer 2")) + expect_equal(flextable::nrow_part(ft, "footer"), 2L) + cleaned <- writetfl:::.clean_flextable(ft) + expect_equal(flextable::nrow_part(cleaned, "footer"), 0L) +}) + +test_that(".clean_flextable is no-op when no footer rows", { + ft <- make_ft() + cleaned <- writetfl:::.clean_flextable(ft) + expect_equal(flextable::nrow_part(cleaned, "footer"), 0L) +}) + +# .flextable_grob_height() ------------------------------------------------ + +test_that(".flextable_grob_height returns positive numeric", { + ft <- make_ft() + grob <- flextable::gen_grob(ft, fit = "auto") + h <- writetfl:::.flextable_grob_height(grob) + expect_true(is.numeric(h)) + expect_true(h > 0) +}) + +# .flextable_to_grob() ---------------------------------------------------- + +test_that(".flextable_to_grob returns a flextableGrob", { + ft <- make_ft() + grob <- writetfl:::.flextable_to_grob(ft, content_w = 10) + expect_true(inherits(grob, "flextableGrob")) + expect_true(inherits(grob, "grob")) +}) + +test_that(".flextable_to_grob scales to content width", { + ft <- make_ft() + grob <- writetfl:::.flextable_to_grob(ft, content_w = 8) + total_w <- sum(grob$ftpar$widths) + # Should be approximately 8 inches (within tolerance for rounding) + expect_true(abs(total_w - 8) < 0.5) +}) + +# .flextable_content_height() --------------------------------------------- + +test_that(".flextable_content_height returns positive numeric", { + annot <- list(caption = "Title", footnote = "Footer") + h <- writetfl:::.flextable_content_height(11, 8.5, list(), "Page {i} of {n}", + annot) + expect_true(is.numeric(h)) + expect_true(h > 0) + expect_true(h < 8.5) +}) + +test_that(".flextable_content_height respects custom dots", { + annot <- list(caption = NULL, footnote = NULL) + h1 <- writetfl:::.flextable_content_height(11, 8.5, list(), "Page {i} of {n}", + annot) + h2 <- writetfl:::.flextable_content_height(11, 8.5, + list(header_left = "Big Header"), + "Page {i} of {n}", annot) + expect_true(h1 > h2) +}) + +test_that(".flextable_content_height respects custom margins via dots", { + annot <- list(caption = NULL, footnote = NULL) + big_margins <- grid::unit(c(2, 2, 2, 2), "inches") + h <- writetfl:::.flextable_content_height(11, 8.5, + list(margins = big_margins), + "Page {i} of {n}", annot) + expect_true(is.numeric(h)) + expect_true(h > 0) + expect_true(h < 4.5) # 8.5 - 4 inches of margins +}) + +# .flextable_content_width() ---------------------------------------------- + +test_that(".flextable_content_width returns positive numeric", { + w <- writetfl:::.flextable_content_width(11, list()) + expect_true(is.numeric(w)) + expect_true(w > 0) + expect_true(w < 11) +}) + +test_that(".flextable_content_width respects custom margins", { + custom_margins <- grid::unit(c(1, 1, 1, 1), "inches") + w <- writetfl:::.flextable_content_width(11, list(margins = custom_margins)) + expect_equal(w, 9) +}) + +# flextable_to_pagelist() ------------------------------------------------- + +test_that("flextable_to_pagelist returns page spec with content and annotations", { + ft <- make_ft(caption = "My Title", footer_lines = "My Footer") + pages <- writetfl:::flextable_to_pagelist(ft) + expect_true(is.list(pages)) + expect_length(pages, 1L) + expect_true(inherits(pages[[1L]]$content, "grob")) + expect_equal(pages[[1L]]$caption, "My Title") + expect_equal(pages[[1L]]$footnote, "My Footer") +}) + +test_that("flextable_to_pagelist works without annotations", { + ft <- make_ft() + pages <- writetfl:::flextable_to_pagelist(ft) + expect_length(pages, 1L) + expect_true(inherits(pages[[1L]]$content, "grob")) + expect_null(pages[[1L]]$caption) + expect_null(pages[[1L]]$footnote) +}) + +# .rebuild_flextable_subset() --------------------------------------------- + +test_that(".rebuild_flextable_subset creates valid sub-flextable", { + ft <- make_ft() + sub_ft <- writetfl:::.rebuild_flextable_subset(ft, 1:3) + expect_true(inherits(sub_ft, "flextable")) + expect_equal(flextable::nrow_part(sub_ft, "body"), 3L) +}) + +test_that(".rebuild_flextable_subset preserves header", { + ft <- make_ft() + sub_ft <- writetfl:::.rebuild_flextable_subset(ft, 1:2) + expect_equal(flextable::nrow_part(sub_ft, "header"), + flextable::nrow_part(ft, "header")) +}) + +test_that(".rebuild_flextable_subset preserves column widths", { + ft <- make_ft() + sub_ft <- writetfl:::.rebuild_flextable_subset(ft, 1:2) + expect_equal(sub_ft$body$colwidths, ft$body$colwidths) +}) + +# .paginate_flextable() --------------------------------------------------- + +test_that(".paginate_flextable splits tall table into multiple pages", { + # Create a table with many rows + big_ft <- flextable::flextable(mtcars[, 1:4]) + big_ft <- writetfl:::.clean_flextable(big_ft) + + # Use a very small content height to force pagination + pages <- writetfl:::.paginate_flextable(big_ft, content_h = 1.5, content_w = 10) + expect_true(length(pages) > 1L) + # All pages should be flextable objects + for (p in pages) { + expect_true(inherits(p, "flextable")) + } + # Total rows across pages should equal original + total_rows <- sum(vapply(pages, function(p) { + flextable::nrow_part(p, "body") + }, integer(1L))) + expect_equal(total_rows, nrow(mtcars)) +}) + +# End-to-end: export_tfl() ------------------------------------------------ + +test_that("export_tfl writes PDF from flextable", { + ft <- make_ft(caption = "Test Table") + tmp <- tempfile(fileext = ".pdf") + on.exit(unlink(tmp), add = TRUE) + result <- export_tfl(ft, file = tmp) + expect_true(file.exists(tmp)) + expect_equal(normalizePath(result), normalizePath(tmp)) +}) + +test_that("export_tfl preview mode works with flextable", { + ft <- make_ft() + grDevices::pdf(NULL) + on.exit(grDevices::dev.off(), add = TRUE) + result <- export_tfl(ft, preview = TRUE) + expect_null(result) +}) + +test_that("export_tfl preview with specific pages works", { + ft <- make_ft() + grDevices::pdf(NULL) + on.exit(grDevices::dev.off(), add = TRUE) + result <- export_tfl(ft, preview = 1L) + expect_null(result) +}) + +# List of flextable objects ----------------------------------------------- + +test_that("export_tfl handles list of flextable objects", { + ft1 <- make_ft(caption = "Table 1") + ft2 <- make_ft(caption = "Table 2") + tmp <- tempfile(fileext = ".pdf") + on.exit(unlink(tmp), add = TRUE) + result <- export_tfl(list(ft1, ft2), file = tmp) + expect_true(file.exists(tmp)) +}) + +test_that("export_tfl preview with list of flextable objects", { + ft1 <- make_ft(caption = "Table 1") + ft2 <- make_ft(caption = "Table 2") + grDevices::pdf(NULL) + on.exit(grDevices::dev.off(), add = TRUE) + result <- export_tfl(list(ft1, ft2), preview = TRUE) + expect_null(result) +}) + +# S3 dispatch -------------------------------------------------------------- + +test_that("S3 method is registered for flextable", { + expect_true(is.function(getS3method("export_tfl", "flextable"))) +}) + +test_that("export_tfl dispatches to flextable method", { + ft <- make_ft() + grDevices::pdf(NULL) + on.exit(grDevices::dev.off(), add = TRUE) + # Should not error — dispatches to export_tfl.flextable + expect_no_error(export_tfl(ft, preview = TRUE)) +}) + +# Pagination end-to-end --------------------------------------------------- + +test_that("tall flextable paginates in export_tfl", { + big_ft <- flextable::flextable(mtcars[, 1:4]) + big_ft <- flextable::set_caption(big_ft, "All mtcars") + big_ft <- flextable::add_footer_lines(big_ft, "Source: datasets package.") + tmp <- tempfile(fileext = ".pdf") + on.exit(unlink(tmp), add = TRUE) + result <- export_tfl(big_ft, file = tmp, pg_height = 5, + min_content_height = grid::unit(1, "inches")) + expect_true(file.exists(tmp)) +}) + +# Preserved features ------------------------------------------------------- + +test_that("borders are preserved in grob output", { + ft <- flextable::flextable(head(mtcars, 3)) + ft <- flextable::border_outer(ft) + grob <- writetfl:::.flextable_to_grob(ft, content_w = 10) + expect_true(inherits(grob, "flextableGrob")) +}) + +test_that("merged cells are preserved in grob output", { + ft <- flextable::flextable(head(mtcars[, 1:3], 4)) + ft <- flextable::merge_v(ft, j = 1) + grob <- writetfl:::.flextable_to_grob(ft, content_w = 10) + expect_true(inherits(grob, "flextableGrob")) +}) + +test_that("theme is preserved in grob output", { + ft <- flextable::flextable(head(mtcars, 3)) + ft <- flextable::theme_vanilla(ft) + grob <- writetfl:::.flextable_to_grob(ft, content_w = 10) + expect_true(inherits(grob, "flextableGrob")) +}) + +# Page layout elements with flextable ------------------------------------ + +test_that("page layout elements work with flextable", { + ft <- make_ft(caption = "Table 1") + grDevices::pdf(NULL) + on.exit(grDevices::dev.off(), add = TRUE) + expect_no_error(export_tfl(ft, preview = TRUE, + header_left = "Study Report", + header_rule = TRUE, + footer_rule = TRUE)) +}) diff --git a/vignettes/v07-flextable.Rmd b/vignettes/v07-flextable.Rmd new file mode 100644 index 0000000..77cef1a --- /dev/null +++ b/vignettes/v07-flextable.Rmd @@ -0,0 +1,231 @@ +--- +title: "Exporting flextable Tables to PDF" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Exporting flextable 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 flextable objects. +For data-frame tables built with `tfl_table()`, see +`vignette("v02-tfl_table_intro")`. For gt tables, see +`vignette("v05-gt_tables")`. For rtables tables, see +`vignette("v06-rtables")`. For figure output, see +`vignette("v01-figure_output")`. + +```{r load} +library(writetfl) +library(flextable) +library(grid) +``` + +--- + +## Basic usage + +Pass a `flextable` object directly to `export_tfl()`. Captions set via +`set_caption()` are automatically extracted and placed in writetfl's caption +zone. Footer rows (from `footnote()` or `add_footer_lines()`) are extracted +into writetfl's footnote zone. + +```{r basic, fig.width = 11, fig.height = 8.5, out.width = "100%"} +ft <- flextable(head(iris, 10)) |> + set_caption("Iris Measurements — First 10 Rows") |> + add_footer_lines("Source: Anderson (1935).") + +export_tfl(ft, preview = TRUE) +``` + +--- + +## Caption handling + +The caption from `set_caption()` is placed in writetfl's caption section +above the table. It does not appear in the `gen_grob()` output (flextable +reserves captions for document formats like Word and HTML), so there is no +duplication. + +```{r caption, fig.width = 11, fig.height = 8.5, out.width = "100%"} +ft <- flextable(head(mtcars[, 1:6], 8)) |> + set_caption("Table 1. Selected Motor Trend Variables") + +export_tfl(ft, preview = TRUE, + header_left = "Appendix A", + header_rule = TRUE, + footer_rule = TRUE +) +``` + +--- + +## Footnote extraction + +Footer rows added via `footnote()` or `add_footer_lines()` are extracted +as plain text and placed in writetfl's footnote zone below the table. The +footer rows are removed from the flextable before rendering so they don't +appear twice. + +When `footnote()` is used, the superscript reference symbols in body cells +are preserved — they still point to the footnotes now positioned in +writetfl's footnote section. + +```{r footnotes, fig.width = 11, fig.height = 8.5, out.width = "100%"} +ft <- flextable(head(iris, 8)) |> + set_caption("Table 2. Iris with Footnotes") + +ft <- footnote(ft, i = 1, j = 1, part = "body", + value = as_paragraph("Measured in centimetres."), + ref_symbols = "a" +) +ft <- footnote(ft, i = 1, j = 3, part = "body", + value = as_paragraph("Petal measurements are less variable."), + ref_symbols = "b" +) + +export_tfl(ft, preview = TRUE, + header_left = "Study Report", + header_rule = TRUE, + footer_rule = TRUE +) +``` + +--- + +## Adding page layout elements + +All of writetfl's page layout arguments work with flextable tables. Pass +them via `...` just as you would for figures. + +```{r layout, fig.width = 11, fig.height = 8.5, out.width = "100%"} +ft <- flextable(head(mtcars[, 1:6], 10)) |> + set_caption("Table 3. Motor Trend Cars") |> + add_footer_lines("Source: Motor Trend (1974).") + +export_tfl( + ft, + preview = TRUE, + header_left = "Study Report", + header_right = format(Sys.Date(), "%d %b %Y"), + header_rule = TRUE, + footer_rule = TRUE +) +``` + +--- + +## Multiple flextable tables + +Pass a list of `flextable` objects to produce a multi-page PDF with one +table per page. + +```{r multi, eval = FALSE} +ft1 <- flextable(head(iris, 10)) |> + set_caption("Table 1. Iris (first 10 rows)") + +ft2 <- flextable(tail(iris, 10)) |> + set_caption("Table 2. Iris (last 10 rows)") + +export_tfl( + list(ft1, ft2), + file = "two-tables.pdf", + header_left = "Appendix", + header_rule = TRUE +) +``` + +--- + +## Automatic pagination + +When a flextable table is too tall to fit on a single page, `export_tfl()` +splits it across pages by subsetting body rows. The header is repeated on +each page. + +```{r pagination, eval = FALSE} +big_ft <- flextable(mtcars[, 1:6]) |> + set_caption("All Motor Trend Cars") |> + add_footer_lines("Source: datasets package.") + +export_tfl( + big_ft, + file = "paginated.pdf", + header_left = "Analysis Report", + header_rule = TRUE, + footer_rule = TRUE +) +``` + +Each page carries the same caption and footnote from the original +flextable object. + +**Note:** When pagination occurs, per-cell formatting applied via +`color()`, `bg()`, `bold()`, etc. is not preserved on paginated pages. +Themes (e.g., `theme_vanilla()`) are not re-applied. For tables with +extensive cell-level formatting, ensure the table fits on a single page +or split the data manually before creating flextable objects. + +--- + +## Preserved features + +The following flextable features are preserved through the `gen_grob()` +rendering pipeline: + +| Feature | Preserved? | Notes | +|---------|:----------:|-------| +| `set_caption()` | Yes | Extracted as writetfl caption | +| `footnote()` | Yes | Text extracted as writetfl footnote; reference symbols preserved in cells | +| `add_footer_lines()` | Yes | Extracted as writetfl footnote | +| `add_header_row()` | Yes | Rendered as part of table header | +| `add_header_lines()` | Yes | Rendered as part of table header | +| `set_header_labels()` | Yes | Column header labels | +| `merge_v()`, `merge_h()`, `merge_at()` | Yes | Cell merging | +| `border()`, `hline()`, `vline()` | Yes | All border styles | +| `color()`, `bg()` | Yes | Text and background colours | +| `bold()`, `italic()` | Yes | Text emphasis | +| `align()`, `align_text_col()` | Yes | Cell alignment | +| `theme_*()` functions | Yes | All built-in themes | +| `colformat_*()` functions | Yes | Number/date formatting | +| `width()`, `height()` | Yes | Column widths scaled to fit page | +| `as_image()`, `as_raster()` | Partial | Images render but require appropriate device | +| `as_equation()` | No | Equations not supported in grid rendering | +| `hyperlink_text()` | No | Hyperlinks not supported in grid/PDF | + +### Themes + +```{r themes, fig.width = 11, fig.height = 8.5, out.width = "100%"} +ft <- flextable(head(iris, 8)) |> + set_caption("Table with Booktabs Theme") |> + theme_booktabs() + +export_tfl(ft, preview = TRUE) +``` + +### Merged cells + +```{r merged, fig.width = 11, fig.height = 8.5, out.width = "100%"} +ft <- flextable(head(iris, 8)) |> + set_caption("Table with Merged Cells") |> + merge_v(j = "Species") + +export_tfl(ft, preview = TRUE) +``` + +### Borders and colours + +```{r borders, fig.width = 11, fig.height = 8.5, out.width = "100%"} +ft <- flextable(head(mtcars[, 1:5], 6)) |> + set_caption("Table with Custom Borders") |> + border_outer(border = officer::fp_border(width = 2)) |> + bg(i = 1, bg = "#E8F0FE", part = "header") + +export_tfl(ft, preview = TRUE) +``` diff --git a/vignettes/writetfl.Rmd b/vignettes/writetfl.Rmd index 1b9b7ec..5895978 100644 --- a/vignettes/writetfl.Rmd +++ b/vignettes/writetfl.Rmd @@ -21,7 +21,8 @@ library(dplyr) ``` `writetfl` produces multi-page PDF files from `ggplot2` figures, data-frame -tables, `gt` tables, `rtables` tables, and other grid content with precise, +tables, `gt` tables, `rtables` tables, `flextable` tables, and other grid +content with precise, composable page layouts required for clinical trial TFL deliverables and regulatory submissions. @@ -237,6 +238,35 @@ passed via `...`. For the full reference see `vignette("v06-rtables")`. --- +## flextable tables + +Pass a `flextable` object directly to `export_tfl()`. Captions (from +`set_caption()`) are extracted into writetfl's caption zone. Footer rows +(from `footnote()` or `add_footer_lines()`) are extracted into writetfl's +footnote zone. The table is rendered via `gen_grob()` with all formatting +preserved. + +```{r flextable-basic, fig.width = 11, fig.height = 8.5, out.width = "100%"} +library(flextable) + +ft <- flextable(head(iris, 10)) |> + set_caption("Iris Measurements") |> + add_footer_lines("Source: Anderson (1935).") + +export_tfl(ft, preview = TRUE, + header_left = "Appendix B", + header_rule = TRUE, + footer_rule = TRUE +) +``` + +A list of `flextable` objects produces a multi-page PDF with one table per +page. For the full reference — caption handling, footnote extraction, +pagination, preserved features, and more — see +`vignette("v07-flextable")`. + +--- + ## Multi-page reports `export_tfl()` accepts a list of page specifications, so different figures can @@ -328,3 +358,4 @@ export_tfl_page( | `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 | +| `vignette("v07-flextable")` | Exporting `flextable` tables: caption/footnote extraction, pagination, preserved features |