From 4f51a49903f09489123394110470f89cd2e26df0 Mon Sep 17 00:00:00 2001 From: Jared Knowles Date: Mon, 22 Jun 2026 16:53:37 -0400 Subject: [PATCH] feat(flextable): add reusable Civilytics flextable branding helpers Generalize the flextable brand styling and "export to PNG + stamp logo" pattern repeated across Civilytics projects into two small functions: - style_flextable_civilytics(): applies only the visual brand (header fill/color, body font/size, zebra striping guarded for <2 rows, borders, footer styling, fixed layout) to an already-structured flextable. Every value is an overridable parameter; zebra toggles striping. Structure (labels, headers, widths, alignment, footer text) stays with the caller. - save_branded_flextable_png(): exports a styled flextable to PNG via ragg::agg_png() sized to the table plus extra_height headroom, then optionally stamps the logo via stamp_logo_png() (... forwarded). flextable, officer, and ragg are added to Suggests (not Imports) to keep the base install light; both functions reference them fully qualified and guard with requireNamespace() + an install hint. Real round-trip tests cover styling, 1-row idempotence, PNG export, and extra_height. --- DESCRIPTION | 5 +- NAMESPACE | 2 + R/flextable.R | 175 ++++++++++++++++++++++++++++++ man/save_branded_flextable_png.Rd | 62 +++++++++++ man/style_flextable_civilytics.Rd | 92 ++++++++++++++++ tests/testthat/test_flextable.R | 89 +++++++++++++++ 6 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 R/flextable.R create mode 100644 man/save_branded_flextable_png.Rd create mode 100644 man/style_flextable_civilytics.Rd create mode 100644 tests/testthat/test_flextable.R diff --git a/DESCRIPTION b/DESCRIPTION index bc780d6..ad27261 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -31,7 +31,10 @@ Encoding: UTF-8 Suggests: testthat (>= 3.0.0), tidycensus, - quarto + quarto, + flextable, + officer, + ragg Config/testthat/edition: 3 Config/roxygen2/version: 8.0.0 RoxygenNote: 7.3.3 diff --git a/NAMESPACE b/NAMESPACE index f562c08..8b11417 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -39,11 +39,13 @@ export(rnh) export(round_to_nearest_half) export(safe_max) export(safe_ratio) +export(save_branded_flextable_png) export(scale_color_civilytics) export(scale_fill_civilytics) export(simpleCap) export(stamp_logo_png) export(star_subs) +export(style_flextable_civilytics) export(theme_civilytics) export(theme_civilytics_dark) export(theme_civilytics_dark_map) diff --git a/R/flextable.R b/R/flextable.R new file mode 100644 index 0000000..8c0d2da --- /dev/null +++ b/R/flextable.R @@ -0,0 +1,175 @@ +#' Apply the Civilytics brand styling to a flextable +#' +#' Applies *only* the visual Civilytics brand to an already-structured +#' [flextable::flextable()] — header fill and color, body font and size, +#' zebra striping, borders, footer styling, and a fixed table layout. The +#' caller remains responsible for table *structure*: labels +#' ([flextable::set_header_labels()]), header/title lines +#' ([flextable::add_header_lines()]), column widths ([flextable::width()]), +#' alignment ([flextable::align()]), and footer text +#' ([flextable::add_footer_lines()]). This separation keeps styling reusable +#' across projects while leaving content decisions where they belong. +#' +#' `flextable`, `officer`, and `ragg` are Suggested (not Imported) to keep the +#' base install light, so this function errors with an install hint if +#' `flextable` is unavailable. +#' +#' @param ft A [flextable::flextable()] object. +#' @param header_bg Character. Header background fill. Default `"#2c3e50"`. +#' @param header_color Character. Header text color. Default `"white"`. +#' @param title_fontsize Numeric. Font size for the first header line (the +#' title row, `i = 1`). Default `14`. +#' @param body_fontsize Numeric. Body font size. Default `11`. +#' @param font_name Character. Font family applied to all parts. Default +#' `"Arial"`. +#' @param zebra Logical. Apply alternating-row striping to even body rows. +#' Default `TRUE`. Safely skipped for tables with fewer than two body rows. +#' @param zebra_bg Character. Fill color for striped (even) body rows. +#' Default `"#f0f0eb"`. +#' @param outer_border_color Character. Outer border color. Default +#' `"#888888"`. +#' @param outer_border_width Numeric. Outer border width. Default `1`. +#' @param inner_border_color Character. Inner horizontal border color (body). +#' Default `"#cccccc"`. +#' @param inner_border_width Numeric. Inner horizontal border width. Default +#' `0.5`. +#' @param footer_fontsize Numeric. Footer font size (applied only if a footer +#' part exists). Default `9`. +#' @param footer_color Character. Footer text color. Default `"#555555"`. +#' +#' @return The styled [flextable::flextable()] object. +#' @export +#' @seealso [save_branded_flextable_png()] to export the styled table to a +#' logo-stamped PNG. +#' @examples +#' \dontrun{ +#' library(flextable) +#' ft <- flextable(head(mtcars)) |> +#' add_header_lines("Motor Trend Cars") |> +#' add_footer_lines("Source: mtcars") |> +#' style_flextable_civilytics() +#' } +style_flextable_civilytics <- function(ft, + header_bg = "#2c3e50", + header_color = "white", + title_fontsize = 14, + body_fontsize = 11, + font_name = "Arial", + zebra = TRUE, + zebra_bg = "#f0f0eb", + outer_border_color = "#888888", + outer_border_width = 1, + inner_border_color = "#cccccc", + inner_border_width = 0.5, + footer_fontsize = 9, + footer_color = "#555555") { + if (!requireNamespace("flextable", quietly = TRUE)) { + stop("Install 'flextable' to use this function.", call. = FALSE) + } + if (!requireNamespace("officer", quietly = TRUE)) { + stop("Install 'officer' to use this function.", call. = FALSE) + } + + # Header: navy fill, white bold text, larger title line. + ft <- flextable::bg(ft, bg = header_bg, part = "header") + ft <- flextable::color(ft, color = header_color, part = "header") + ft <- flextable::bold(ft, part = "header") + ft <- flextable::fontsize(ft, i = 1, size = title_fontsize, part = "header") + + # Body: readable size, brand font across all parts. + ft <- flextable::fontsize(ft, size = body_fontsize, part = "body") + ft <- flextable::font(ft, fontname = font_name, part = "all") + + # Zebra striping on even body rows, guarded for tables with < 2 rows. + nrow_body <- flextable::nrow_part(ft, part = "body") + if (zebra && nrow_body >= 2) { + ft <- flextable::bg(ft, i = seq(2, nrow_body, 2), bg = zebra_bg, + part = "body") + } + + # Borders: outer frame on all parts, light horizontal rules in the body. + ft <- flextable::border_outer( + ft, + border = officer::fp_border(color = outer_border_color, + width = outer_border_width), + part = "all" + ) + ft <- flextable::border_inner_h( + ft, + border = officer::fp_border(color = inner_border_color, + width = inner_border_width), + part = "body" + ) + + # Footer styling — only if the table actually has a footer part. + if (flextable::nrow_part(ft, part = "footer") > 0) { + ft <- flextable::fontsize(ft, size = footer_fontsize, part = "footer") + ft <- flextable::color(ft, color = footer_color, part = "footer") + } + + flextable::set_table_properties(ft, layout = "fixed") +} + + +#' Save a branded flextable to a logo-stamped PNG +#' +#' Exports a styled [flextable::flextable()] to a PNG via +#' [ragg::agg_png()] (sized to the table's own dimensions plus a little +#' headroom for the logo), then optionally stamps the Civilytics logo onto the +#' file with [stamp_logo_png()] so file-based tables stay visually consistent +#' with [civilytics_logo()]-branded plots. +#' +#' `ragg` and `flextable` are Suggested (not Imported); this function errors +#' with an install hint if either is unavailable. +#' +#' @param ft A [flextable::flextable()] object, typically already styled with +#' [style_flextable_civilytics()]. +#' @param path Character. Output PNG path. Returned invisibly. +#' @param logo Logical. Stamp the Civilytics logo onto the saved PNG via +#' [stamp_logo_png()]. Default `TRUE`. +#' @param res Numeric. Output resolution in PPI passed to [ragg::agg_png()]. +#' Default `300`. +#' @param extra_height Numeric. Additional height in inches added to the +#' table's natural height to leave room for the stamped logo. Default `0.4`. +#' @param ... Additional arguments forwarded to [stamp_logo_png()] (e.g. +#' `type`, `variant`, `position`, `width_frac`, `margin_frac`). +#' +#' @return `path`, invisibly. +#' @export +#' @seealso [style_flextable_civilytics()] to apply the brand styling, and +#' [stamp_logo_png()] for the underlying logo compositing. +#' @examples +#' \dontrun{ +#' library(flextable) +#' ft <- flextable(head(mtcars)) |> +#' add_footer_lines("Source: mtcars") |> +#' style_flextable_civilytics() +#' save_branded_flextable_png(ft, "table.png") +#' save_branded_flextable_png(ft, "table.png", position = "bottom-left") +#' knitr::include_graphics("table.png") +#' } +save_branded_flextable_png <- function(ft, path, logo = TRUE, res = 300, + extra_height = 0.4, ...) { + if (!requireNamespace("flextable", quietly = TRUE)) { + stop("Install 'flextable' to use this function.", call. = FALSE) + } + if (!requireNamespace("ragg", quietly = TRUE)) { + stop("Install 'ragg' to use this function.", call. = FALSE) + } + + d <- flextable::flextable_dim(ft) + ragg::agg_png(path, width = d$width, height = d$height + extra_height, + units = "in", res = res) + on.exit(grDevices::dev.off(), add = TRUE) + plot(ft) + + if (logo) { + # dev.off() must run before stamp_logo_png() re-reads the file. Flush the + # device now and clear the on.exit handler so it does not fire twice. + grDevices::dev.off() + on.exit() + stamp_logo_png(path, ...) + } + + invisible(path) +} diff --git a/man/save_branded_flextable_png.Rd b/man/save_branded_flextable_png.Rd new file mode 100644 index 0000000..24d5555 --- /dev/null +++ b/man/save_branded_flextable_png.Rd @@ -0,0 +1,62 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/flextable.R +\name{save_branded_flextable_png} +\alias{save_branded_flextable_png} +\title{Save a branded flextable to a logo-stamped PNG} +\usage{ +save_branded_flextable_png( + ft, + path, + logo = TRUE, + res = 300, + extra_height = 0.4, + ... +) +} +\arguments{ +\item{ft}{A [flextable::flextable()] object, typically already styled with +[style_flextable_civilytics()].} + +\item{path}{Character. Output PNG path. Returned invisibly.} + +\item{logo}{Logical. Stamp the Civilytics logo onto the saved PNG via +[stamp_logo_png()]. Default `TRUE`.} + +\item{res}{Numeric. Output resolution in PPI passed to [ragg::agg_png()]. +Default `300`.} + +\item{extra_height}{Numeric. Additional height in inches added to the +table's natural height to leave room for the stamped logo. Default `0.4`.} + +\item{...}{Additional arguments forwarded to [stamp_logo_png()] (e.g. +`type`, `variant`, `position`, `width_frac`, `margin_frac`).} +} +\value{ +`path`, invisibly. +} +\description{ +Exports a styled [flextable::flextable()] to a PNG via +[ragg::agg_png()] (sized to the table's own dimensions plus a little +headroom for the logo), then optionally stamps the Civilytics logo onto the +file with [stamp_logo_png()] so file-based tables stay visually consistent +with [civilytics_logo()]-branded plots. +} +\details{ +`ragg` and `flextable` are Suggested (not Imported); this function errors +with an install hint if either is unavailable. +} +\examples{ +\dontrun{ +library(flextable) +ft <- flextable(head(mtcars)) |> + add_footer_lines("Source: mtcars") |> + style_flextable_civilytics() +save_branded_flextable_png(ft, "table.png") +save_branded_flextable_png(ft, "table.png", position = "bottom-left") +knitr::include_graphics("table.png") +} +} +\seealso{ +[style_flextable_civilytics()] to apply the brand styling, and + [stamp_logo_png()] for the underlying logo compositing. +} diff --git a/man/style_flextable_civilytics.Rd b/man/style_flextable_civilytics.Rd new file mode 100644 index 0000000..ba75d1a --- /dev/null +++ b/man/style_flextable_civilytics.Rd @@ -0,0 +1,92 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/flextable.R +\name{style_flextable_civilytics} +\alias{style_flextable_civilytics} +\title{Apply the Civilytics brand styling to a flextable} +\usage{ +style_flextable_civilytics( + ft, + header_bg = "#2c3e50", + header_color = "white", + title_fontsize = 14, + body_fontsize = 11, + font_name = "Arial", + zebra = TRUE, + zebra_bg = "#f0f0eb", + outer_border_color = "#888888", + outer_border_width = 1, + inner_border_color = "#cccccc", + inner_border_width = 0.5, + footer_fontsize = 9, + footer_color = "#555555" +) +} +\arguments{ +\item{ft}{A [flextable::flextable()] object.} + +\item{header_bg}{Character. Header background fill. Default `"#2c3e50"`.} + +\item{header_color}{Character. Header text color. Default `"white"`.} + +\item{title_fontsize}{Numeric. Font size for the first header line (the +title row, `i = 1`). Default `14`.} + +\item{body_fontsize}{Numeric. Body font size. Default `11`.} + +\item{font_name}{Character. Font family applied to all parts. Default +`"Arial"`.} + +\item{zebra}{Logical. Apply alternating-row striping to even body rows. +Default `TRUE`. Safely skipped for tables with fewer than two body rows.} + +\item{zebra_bg}{Character. Fill color for striped (even) body rows. +Default `"#f0f0eb"`.} + +\item{outer_border_color}{Character. Outer border color. Default +`"#888888"`.} + +\item{outer_border_width}{Numeric. Outer border width. Default `1`.} + +\item{inner_border_color}{Character. Inner horizontal border color (body). +Default `"#cccccc"`.} + +\item{inner_border_width}{Numeric. Inner horizontal border width. Default +`0.5`.} + +\item{footer_fontsize}{Numeric. Footer font size (applied only if a footer +part exists). Default `9`.} + +\item{footer_color}{Character. Footer text color. Default `"#555555"`.} +} +\value{ +The styled [flextable::flextable()] object. +} +\description{ +Applies *only* the visual Civilytics brand to an already-structured +[flextable::flextable()] — header fill and color, body font and size, +zebra striping, borders, footer styling, and a fixed table layout. The +caller remains responsible for table *structure*: labels +([flextable::set_header_labels()]), header/title lines +([flextable::add_header_lines()]), column widths ([flextable::width()]), +alignment ([flextable::align()]), and footer text +([flextable::add_footer_lines()]). This separation keeps styling reusable +across projects while leaving content decisions where they belong. +} +\details{ +`flextable`, `officer`, and `ragg` are Suggested (not Imported) to keep the +base install light, so this function errors with an install hint if +`flextable` is unavailable. +} +\examples{ +\dontrun{ +library(flextable) +ft <- flextable(head(mtcars)) |> + add_header_lines("Motor Trend Cars") |> + add_footer_lines("Source: mtcars") |> + style_flextable_civilytics() +} +} +\seealso{ +[save_branded_flextable_png()] to export the styled table to a + logo-stamped PNG. +} diff --git a/tests/testthat/test_flextable.R b/tests/testthat/test_flextable.R new file mode 100644 index 0000000..a37c476 --- /dev/null +++ b/tests/testthat/test_flextable.R @@ -0,0 +1,89 @@ +test_that("style_flextable_civilytics returns a styled flextable", { + skip_if_not_installed("flextable") + skip_if_not_installed("officer") + + ft <- flextable::flextable(head(mtcars, 4)) + ft <- flextable::add_footer_lines(ft, "Source: mtcars") + styled <- style_flextable_civilytics(ft) + + expect_s3_class(styled, "flextable") + # Fixed layout is the documented end-state of the styling. + expect_identical(styled$properties$layout, "fixed") +}) + +test_that("style_flextable_civilytics is idempotent on a 1-row table (no zebra error)", { + skip_if_not_installed("flextable") + skip_if_not_installed("officer") + + ft <- flextable::flextable(head(mtcars, 1)) + expect_no_error(once <- style_flextable_civilytics(ft)) + # Re-styling an already-styled table must not error either. + expect_no_error(twice <- style_flextable_civilytics(once)) + expect_s3_class(twice, "flextable") +}) + +test_that("zebra = FALSE still returns a valid flextable", { + skip_if_not_installed("flextable") + skip_if_not_installed("officer") + + ft <- flextable::flextable(head(mtcars, 6)) + expect_s3_class(style_flextable_civilytics(ft, zebra = FALSE), "flextable") +}) + +test_that("save_branded_flextable_png writes a PNG with plausible dimensions", { + skip_if_not_installed("flextable") + skip_if_not_installed("officer") + skip_if_not_installed("ragg") + skip_if_not_installed("png") + + ft <- flextable::flextable(head(mtcars, 4)) + ft <- flextable::add_footer_lines(ft, "Source: mtcars") + ft <- style_flextable_civilytics(ft) + + path <- tempfile(fileext = ".png") + on.exit(unlink(path), add = TRUE) + + out <- save_branded_flextable_png(ft, path) + + expect_identical(out, path) + expect_true(file.exists(path)) + + dims <- dim(png::readPNG(path)) + # height x width x channels — a real table is at least a few hundred px each. + expect_gt(dims[1], 50) + expect_gt(dims[2], 50) +}) + +test_that("save_branded_flextable_png with logo = FALSE skips stamping but still writes", { + skip_if_not_installed("flextable") + skip_if_not_installed("officer") + skip_if_not_installed("ragg") + skip_if_not_installed("png") + + ft <- style_flextable_civilytics(flextable::flextable(head(mtcars, 3))) + + path <- tempfile(fileext = ".png") + on.exit(unlink(path), add = TRUE) + + save_branded_flextable_png(ft, path, logo = FALSE) + expect_true(file.exists(path)) + expect_gt(file.info(path)$size, 0) +}) + +test_that("extra_height increases the exported PNG height", { + skip_if_not_installed("flextable") + skip_if_not_installed("officer") + skip_if_not_installed("ragg") + skip_if_not_installed("png") + + ft <- style_flextable_civilytics(flextable::flextable(head(mtcars, 4))) + + path_small <- tempfile(fileext = ".png") + path_big <- tempfile(fileext = ".png") + on.exit(unlink(c(path_small, path_big)), add = TRUE) + + save_branded_flextable_png(ft, path_small, logo = FALSE, extra_height = 0) + save_branded_flextable_png(ft, path_big, logo = FALSE, extra_height = 2) + + expect_gt(dim(png::readPNG(path_big))[1], dim(png::readPNG(path_small))[1]) +})