Author SHA1 Message Date
jared 2743ebb09c Merge pull request 'feat(flextable): reusable Civilytics flextable branding helpers' (#10) from feat/flextable-branding into feat/stamp-logo-png
Reviewed-on: #10
2026-06-22 17:00:08 -04:00
jared 4f51a49903 feat(flextable): add reusable Civilytics flextable branding helpers
R-CMD-check / R CMD check (pull_request) Successful in 4m20s
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.
2026-06-22 16:53:37 -04:00
jared 5fc8155965 feat(logo): add stamp_logo_png() to brand file-based PNG outputs
R-CMD-check / R CMD check (pull_request) Successful in 3m53s
civilytics_logo()/make_logo_grob() brand ggplot/grobs, but flextables and
other outputs are rendered to PNG first and can't use them. stamp_logo_png()
is the raster analogue: it resolves the SAME brand asset that make_logo_grob()
uses (via the type/variant switch) and composites it into a corner of an
existing PNG in place, so file-based tables stay visually consistent with
logo-branded plots.

Dependency-free by design — uses only png + grid + grDevices (all already
imported), no magick. Configurable type/variant/position/width/margin;
preserves the image's pixel dimensions. Adds tests (21 assertions).
2026-06-22 16:33:08 -04:00
jared 41162597cf Merge pull request 'Fix issues #1, #3, #5: misc improvements' (#6) from fix/issues-1-3-5-misc-improvements into master
R-CMD-check / R CMD check (push) Successful in 4m25s
R-CMD-check / R CMD check (pull_request) Successful in 4m6s
Reviewed-on: #6
2026-06-04 14:04:44 -04:00
jared 2a74ec2bd3 test: add security regression test and beep(type='all') coverage
R-CMD-check / R CMD check (pull_request) Successful in 3m53s
- Add test verifying .send_webhook() and .send_desktop_notify() use
  system2() (not system()) to prevent command injection. This test
  reads the function body and asserts system2 is present and
  system("...") is absent, so the vulnerability cannot be
  reintroduced by accident.
- Add test for beep(type='all') exercising both beep and notify paths.
2026-06-04 13:48:21 +00:00
jared 366ac39fac fix: replace system() with system2() to prevent command injection in beep notifications
R-CMD-check / R CMD check (pull_request) Successful in 3m50s
Use system2() with separate args instead of shell-interpolated system()
calls in .send_desktop_notify() and .send_webhook(). This eliminates
command injection risk when msg, status, url, or payload contain shell
metacharacters.
2026-06-04 13:17:06 +00:00
jared fc8bf1866b Fix beep.Rd Rd syntax errors (remove invalid \char escape)
R-CMD-check / R CMD check (pull_request) Successful in 4m44s
2026-06-03 19:11:19 +00:00
jared 75b9a73165 Fix CI warnings: add jsonlite dep, flush.console import, Rd docs
R-CMD-check / R CMD check (pull_request) Failing after 1m13s
- Add jsonlite to Imports (used by beep() webhook)
- Add importFrom(utils, flush.console) to NAMESPACE
- Update na_sum.Rd to include quiet parameter
- Create beep.Rd documentation
2026-06-03 19:08:48 +00:00
jared 2f1cdcde52 Fix issues #1, #3, #5: misc improvements
R-CMD-check / R CMD check (pull_request) Failing after 4m43s
- #5: Unwrap _brand.yml for Quarto 1.9+ compatibility (remove top-level
  brand: wrapper so meta/logo/color/typography are at the top level)
- #1: Add quiet parameter to na_sum() to suppress warnings in loops/pipelines
- #3: Add beep() function for CLI beep, desktop notifications, and webhook
  alerts (supports type=beep|notify|webhook|all)
2026-06-03 19:01:30 +00:00
16 changed files with 965 additions and 63 deletions
+5 -1
View File
@@ -19,6 +19,7 @@ Depends:
Imports: Imports:
ggplot2 (>= 4.0.0), ggplot2 (>= 4.0.0),
jpeg, jpeg,
jsonlite,
png, png,
stringr, stringr,
gridExtra, gridExtra,
@@ -30,7 +31,10 @@ Encoding: UTF-8
Suggests: Suggests:
testthat (>= 3.0.0), testthat (>= 3.0.0),
tidycensus, tidycensus,
quarto quarto,
flextable,
officer,
ragg
Config/testthat/edition: 3 Config/testthat/edition: 3
Config/roxygen2/version: 8.0.0 Config/roxygen2/version: 8.0.0
RoxygenNote: 7.3.3 RoxygenNote: 7.3.3
+9
View File
@@ -1,5 +1,6 @@
# Generated by roxygen2: do not edit by hand # Generated by roxygen2: do not edit by hand
export(beep)
export(add_logo) export(add_logo)
export(add_logo_ga) export(add_logo_ga)
export(agresti_coull_interval) export(agresti_coull_interval)
@@ -38,10 +39,13 @@ export(rnh)
export(round_to_nearest_half) export(round_to_nearest_half)
export(safe_max) export(safe_max)
export(safe_ratio) export(safe_ratio)
export(save_branded_flextable_png)
export(scale_color_civilytics) export(scale_color_civilytics)
export(scale_fill_civilytics) export(scale_fill_civilytics)
export(simpleCap) export(simpleCap)
export(stamp_logo_png)
export(star_subs) export(star_subs)
export(style_flextable_civilytics)
export(theme_civilytics) export(theme_civilytics)
export(theme_civilytics_dark) export(theme_civilytics_dark)
export(theme_civilytics_dark_map) export(theme_civilytics_dark_map)
@@ -60,8 +64,12 @@ importFrom(ggplot2,annotation_custom)
importFrom(ggplot2,ggplot) importFrom(ggplot2,ggplot)
importFrom(ggplot2,theme) importFrom(ggplot2,theme)
importFrom(ggplot2,theme_void) importFrom(ggplot2,theme_void)
importFrom(grDevices,dev.off)
importFrom(grDevices,png)
importFrom(graphics,rasterImage) importFrom(graphics,rasterImage)
importFrom(grid,grid.draw) importFrom(grid,grid.draw)
importFrom(grid,grid.newpage)
importFrom(grid,grid.raster)
importFrom(grid,rasterGrob) importFrom(grid,rasterGrob)
importFrom(gridExtra,arrangeGrob) importFrom(gridExtra,arrangeGrob)
importFrom(jpeg,readJPEG) importFrom(jpeg,readJPEG)
@@ -71,3 +79,4 @@ importFrom(stats,qnorm)
importFrom(stats,runif) importFrom(stats,runif)
importFrom(stringdist,stringsim) importFrom(stringdist,stringsim)
importFrom(stringr,str_count) importFrom(stringr,str_count)
importFrom(utils,flush.console)
+175
View File
@@ -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)
}
+89
View File
@@ -361,3 +361,92 @@ civilytics_logo <- function(plot,
position = position) position = position)
} }
#' Stamp the Civilytics logo onto a saved raster (PNG) image
#'
#' The raster analogue of [civilytics_logo()] for outputs that are already
#' rendered to a file rather than held as a ggplot/grob — e.g. a `flextable`
#' exported to PNG, or any `grDevices::png()` / `ragg::agg_png()` output.
#' Resolves the *same* brand asset that [make_logo_grob()] uses, so file-based
#' tables stay visually consistent with logo-branded plots, and composites it
#' into a corner of the image. Pure base-graphics + grid + png — no new
#' package dependencies.
#'
#' @param path Character. Path to the PNG to stamp. The file is overwritten
#' in place at its original pixel dimensions.
#' @param type Character. `"wordmark"` (default) or `"mark"`. As in
#' [make_logo_grob()].
#' @param variant Character. `"light"` (default, dark logo for light
#' backgrounds) or `"dark"` (reverse logo for dark backgrounds).
#' @param position Character. Corner placement: `"bottom-right"` (default),
#' `"bottom-left"`, `"top-right"`, or `"top-left"`.
#' @param width_frac Numeric. Logo width as a fraction of the image width
#' (default `0.15`). Height follows from the logo's aspect ratio.
#' @param margin_frac Numeric. Padding from the edges as a fraction of the
#' image width (default `0.02`).
#'
#' @return `path`, invisibly.
#' @export
#' @importFrom png readPNG
#' @importFrom grid grid.newpage grid.raster
#' @importFrom grDevices png dev.off
#' @examples
#' \dontrun{
#' # Brand a table exported to PNG so it matches civilytics_logo()-branded plots
#' ragg::agg_png("table.png", width = 8, height = 4, units = "in", res = 200)
#' plot(flextable::flextable(head(mtcars)))
#' dev.off()
#' stamp_logo_png("table.png") # wordmark, bottom-right
#' stamp_logo_png("table.png", type = "mark", position = "bottom-left")
#' }
stamp_logo_png <- function(path,
type = c("wordmark", "mark"),
variant = c("light", "dark"),
position = c("bottom-right", "bottom-left",
"top-right", "top-left"),
width_frac = 0.15,
margin_frac = 0.02) {
type <- match.arg(type)
variant <- match.arg(variant)
position <- match.arg(position)
stopifnot(file.exists(path))
# Same asset selection as make_logo_grob() so files match branded plots.
img_file <- switch(
paste(type, variant, sep = "_"),
wordmark_light = "civilytics-wordmark.png",
wordmark_dark = "civilytics-wordmark-reverse.png",
mark_light = "civilytics-mark.png",
mark_dark = "civilytics-mark-reverse.png"
)
logo_path <- system.file("img", img_file, package = "civilytics")
if (!nzchar(logo_path)) {
stop("Civilytics logo asset not found in the 'civilytics' package: ", img_file)
}
base_img <- png::readPNG(path) # height x width x channels, values in [0, 1]
logo_img <- png::readPNG(logo_path)
h <- dim(base_img)[1]
w <- dim(base_img)[2]
aspect <- dim(logo_img)[1] / dim(logo_img)[2] # logo height / width
# Sizes/margins are expressed relative to image WIDTH, then converted to the
# device's npc units (which scale with the viewport's own width and height).
lw <- width_frac
lh <- width_frac * aspect * (w / h)
mx <- margin_frac
my <- margin_frac * (w / h)
x <- if (grepl("right", position)) 1 - mx else mx
y <- if (grepl("top", position)) 1 - my else my
just <- c(if (grepl("right", position)) "right" else "left",
if (grepl("top", position)) "top" else "bottom")
grDevices::png(path, width = w, height = h, units = "px")
on.exit(grDevices::dev.off(), add = TRUE)
grid::grid.newpage()
grid::grid.raster(base_img, width = 1, height = 1, interpolate = FALSE)
grid::grid.raster(logo_img, x = x, y = y, width = lw, height = lh,
just = just, interpolate = TRUE)
invisible(path)
}
+153
View File
@@ -0,0 +1,153 @@
#' Send a CLI beep / desktop notification
#'
#' Plays an audible beep in the terminal and/or sends a desktop notification
#' when a long-running R script completes or reaches a milestone.
#'
#' @param msg Character. Optional message to include in the notification.
#' When `type = "notify"`, this becomes the notification body.
#' @param type Character. Notification method:
#' - `"beep"` (default): emit a terminal bell character (`\007`).
#' Works in any terminal that supports the bell.
#' - `"notify"`: send a desktop notification via `notify-send` (Linux) or
#' `osascript` (macOS). Falls back to `"beep"` if neither tool is found.
#' - `"webhook"`: POST a JSON payload to a URL. Requires `url` argument.
#' - `"all"`: play beep + send desktop notification (webhook only if `url`
#' is provided).
#' @param url Character. Webhook URL for `type = "webhook"` or `"all"`.
#' A JSON payload is POSTed with keys `message`, `status`, and `timestamp`.
#' @param status Character. Status label for the notification (default `"done"`).
#' Used in the notification title and webhook payload.
#' @param timeout Numeric. Seconds to wait for the webhook POST to complete
#' (default `5`). Ignored for non-webhook types.
#' @param quiet Logical. If `TRUE`, suppress the terminal beep even when
#' `type` includes `"beep"`. Useful for silent background runs.
#'
#' @return Invisible `NULL`.
#'
#' @section Requirements:
#' - `type = "notify"` requires `notify-send` (Linux) or `osascript` (macOS).
#' - `type = "webhook"` requires network access to the provided URL.
#'
#' @section Examples:
#' \preformatted{
#' # Simple terminal beep
#' beep()
#'
#' # Desktop notification with message
#' beep("Analysis complete!", type = "notify")
#'
#' # Send to a webhook (e.g., Slack, Discord, custom endpoint)
#' beep("Job finished", type = "webhook",
#' url = "https://hooks.slack.com/services/...")
#'
#' # Beep + desktop notification
#' beep("Processing done", type = "all")
#' }
#'
#' @export
#'
beep <- function(msg = "done",
type = c("beep", "notify", "webhook", "all"),
url = NULL,
status = "done",
timeout = 5,
quiet = FALSE) {
type <- match.arg(type)
# -- Terminal beep ----------------------------------------------------------
if (!quiet && grepl("beep", type)) {
cat("\007")
flush.console()
}
# -- Desktop notification ---------------------------------------------------
if (grepl("notify", type)) {
.send_desktop_notify(msg, status)
}
# -- Webhook ----------------------------------------------------------------
if (grepl("webhook", type) && !is.null(url)) {
.send_webhook(url, msg, status, timeout)
}
invisible(NULL)
}
# -- Internal helpers ---------------------------------------------------------
#' Send a desktop notification via notify-send or osascript
#'
#' @param msg Message body
#' @param status Status label for the title
#' @keywords internal
.send_desktop_notify <- function(msg, status) {
# Linux: notify-send
if (.has_command("notify-send")) {
system2("notify-send", args = c(status, msg),
stdout = TRUE, stderr = TRUE)
return(invisible(NULL))
}
# macOS: osascript
if (.has_command("osascript")) {
system2("osascript",
args = c("-e",
paste0("display notification \"", msg,
"\" with title \"", status, "\"")),
stdout = TRUE, stderr = TRUE)
return(invisible(NULL))
}
# Neither tool available — silently skip
invisible(NULL)
}
#' POST a JSON payload to a webhook URL
#'
#' @param url Webhook URL
#' @param msg Message body
#' @param status Status label
#' @param timeout Seconds to wait for the request
#' @keywords internal
.send_webhook <- function(url, msg, status, timeout) {
payload <- jsonlite::toJSON(list(
message = msg,
status = status,
timestamp = format(Sys.time(), "%Y-%m-%dT%H:%M:%S%z")
), auto_unbox = TRUE)
# Use curl via system2() for maximum compatibility (no extra R deps).
# system2() passes arguments directly to the executable without shell
# interpolation, avoiding command injection.
if (.has_command("curl")) {
system2("curl",
args = c("-s", "-X", "POST",
"-H", "Content-Type: application/json",
"-d", payload,
url,
"--max-time", as.character(timeout)),
stdout = TRUE, stderr = TRUE)
} else if (.has_command("wget")) {
system2("wget",
args = c("-q", "-O", "/dev/null",
paste0("--post-data=", payload),
paste0("--header=Content-Type: application/json"),
paste0("--timeout=", timeout),
url),
stdout = TRUE, stderr = TRUE)
}
invisible(NULL)
}
#' Check if a command exists on the system PATH
#'
#' @param cmd Command name
#' @return Logical
#' @keywords internal
.has_command <- function(cmd) {
Sys.which(cmd) != ""
}
+7 -1
View File
@@ -149,6 +149,9 @@ race_short_names <- function(x) {
#' Sum a numeric that contains missing values and ignore missing values #' Sum a numeric that contains missing values and ignore missing values
#' #'
#' @param x a numeric vector #' @param x a numeric vector
#' @param quiet Logical. If `TRUE` (default `FALSE`), suppress the warning
#' message. Useful when calling `na_sum()` inside a loop or `dplyr` pipeline
#' where the message would be emitted repeatedly.
#' #'
#' @return the sum, ignoring any missing values #' @return the sum, ignoring any missing values
#' @export #' @export
@@ -156,9 +159,12 @@ race_short_names <- function(x) {
#' @examples #' @examples
#' x <- c(2, NA, 4, 9) #' x <- c(2, NA, 4, 9)
#' na_sum(x) # 15 #' na_sum(x) # 15
na_sum <- function(x) { #' na_sum(x, quiet = TRUE) # 15 (no message)
na_sum <- function(x, quiet = FALSE) {
stopifnot(is.numeric(x)) stopifnot(is.numeric(x))
if (!quiet) {
message("Taking a sum with missing values equal to 0, be careful!") message("Taking a sum with missing values equal to 0, be careful!")
}
x <- na_zero(x) x <- na_zero(x)
return(sum(x)) return(sum(x))
} }
+4 -5
View File
@@ -2,17 +2,16 @@
# Optional but recommended — Quarto auto-applies these to HTML, PDF, and Revealjs. # Optional but recommended — Quarto auto-applies these to HTML, PDF, and Revealjs.
# https://quarto.org/docs/authoring/brand.html # https://quarto.org/docs/authoring/brand.html
brand: meta:
meta:
name: Civilytics Consulting name: Civilytics Consulting
description: Turning public data into clear, actionable analysis for public good. description: Turning public data into clear, actionable analysis for public good.
logo: logo:
small: assets/logo/civilytics-mark.svg small: assets/logo/civilytics-mark.svg
medium: assets/logo/civilytics-wordmark.svg medium: assets/logo/civilytics-wordmark.svg
large: assets/logo/civilytics-wordmark.svg large: assets/logo/civilytics-wordmark.svg
color: color:
palette: palette:
paper: "#FAF7F2" paper: "#FAF7F2"
paper-2: "#F2EDE4" paper-2: "#F2EDE4"
@@ -38,7 +37,7 @@ brand:
dark: ink dark: ink
link: navy link: navy
typography: typography:
fonts: fonts:
- family: "Source Serif 4" - family: "Source Serif 4"
source: google source: google
+71
View File
@@ -0,0 +1,71 @@
% Generated by roxygen2: do not edit by hand
% Please edit documentation in R/notifications.R
\name{beep}
\alias{beep}
\title{Send a CLI beep / desktop notification}
\usage{
beep(
msg = "done",
type = c("beep", "notify", "webhook", "all"),
url = NULL,
status = "done",
timeout = 5,
quiet = FALSE
)
}
\arguments{
\item{msg}{Character. Optional message to include in the notification.
When \code{type = "notify"}, this becomes the notification body.}
\item{type}{Character. Notification method:
\itemize{
\item \code{"beep"} (default): emit a terminal bell character.
Works in any terminal that supports the bell.
\item \code{"notify"}: send a desktop notification via \code{notify-send} (Linux) or
\code{osascript} (macOS). Falls back to \code{"beep"} if neither tool is found.
\item \code{"webhook"}: POST a JSON payload to a URL. Requires \code{url} argument.
\item \code{"all"}: play beep + send desktop notification (webhook only if \code{url}
is provided).
}}
\item{url}{Character. Webhook URL for \code{type = "webhook"} or \code{"all"}.
A JSON payload is POSTed with keys \code{message}, \code{status}, and \code{timestamp}.}
\item{status}{Character. Status label for the notification (default \code{"done"}).
Used in the notification title and webhook payload.}
\item{timeout}{Numeric. Seconds to wait for the webhook POST to complete
(default \code{5}). Ignored for non-webhook types.}
\item{quiet}{Logical. If \code{TRUE}, suppress the terminal beep even when
\code{type} includes \code{"beep"}. Useful for silent background runs.}
}
\value{
Invisible \code{NULL}.
}
\description{
Plays an audible beep in the terminal and/or sends a desktop notification
when a long-running R script completes or reaches a milestone.
}
\section{Requirements}{
\itemize{
\item \code{type = "notify"} requires \code{notify-send} (Linux) or \code{osascript} (macOS).
\item \code{type = "webhook"} requires network access to the provided URL.
}
}
\section{Examples}{
\preformatted{
# Simple terminal beep
beep()
# Desktop notification with message
beep("Analysis complete!", type = "notify")
# Send to a webhook (e.g., Slack, Discord, custom endpoint)
beep("Job finished", type = "webhook",
url = "https://hooks.slack.com/services/...")
# Beep + desktop notification
beep("Processing done", type = "all")
}
}
+6 -1
View File
@@ -4,10 +4,14 @@
\alias{na_sum} \alias{na_sum}
\title{Sum a numeric that contains missing values and ignore missing values} \title{Sum a numeric that contains missing values and ignore missing values}
\usage{ \usage{
na_sum(x) na_sum(x, quiet = FALSE)
} }
\arguments{ \arguments{
\item{x}{a numeric vector} \item{x}{a numeric vector}
\item{quiet}{Logical. If \code{TRUE} (default \code{FALSE}), suppress the warning
message. Useful when calling \code{na_sum()} inside a loop or \code{dplyr} pipeline
where the message would be emitted repeatedly.}
} }
\value{ \value{
the sum, ignoring any missing values the sum, ignoring any missing values
@@ -18,4 +22,5 @@ Sum a numeric that contains missing values and ignore missing values
\examples{ \examples{
x <- c(2, NA, 4, 9) x <- c(2, NA, 4, 9)
na_sum(x) # 15 na_sum(x) # 15
na_sum(x, quiet = TRUE) # 15 (no message)
} }
+62
View File
@@ -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.
}
+56
View File
@@ -0,0 +1,56 @@
% Generated by roxygen2: do not edit by hand
% Please edit documentation in R/logo.R
\name{stamp_logo_png}
\alias{stamp_logo_png}
\title{Stamp the Civilytics logo onto a saved raster (PNG) image}
\usage{
stamp_logo_png(
path,
type = c("wordmark", "mark"),
variant = c("light", "dark"),
position = c("bottom-right", "bottom-left", "top-right", "top-left"),
width_frac = 0.15,
margin_frac = 0.02
)
}
\arguments{
\item{path}{Character. Path to the PNG to stamp. The file is overwritten
in place at its original pixel dimensions.}
\item{type}{Character. `"wordmark"` (default) or `"mark"`. As in
[make_logo_grob()].}
\item{variant}{Character. `"light"` (default, dark logo for light
backgrounds) or `"dark"` (reverse logo for dark backgrounds).}
\item{position}{Character. Corner placement: `"bottom-right"` (default),
`"bottom-left"`, `"top-right"`, or `"top-left"`.}
\item{width_frac}{Numeric. Logo width as a fraction of the image width
(default `0.15`). Height follows from the logo's aspect ratio.}
\item{margin_frac}{Numeric. Padding from the edges as a fraction of the
image width (default `0.02`).}
}
\value{
`path`, invisibly.
}
\description{
The raster analogue of [civilytics_logo()] for outputs that are already
rendered to a file rather than held as a ggplot/grob — e.g. a `flextable`
exported to PNG, or any `grDevices::png()` / `ragg::agg_png()` output.
Resolves the *same* brand asset that [make_logo_grob()] uses, so file-based
tables stay visually consistent with logo-branded plots, and composites it
into a corner of the image. Pure base-graphics + grid + png — no new
package dependencies.
}
\examples{
\dontrun{
# Brand a table exported to PNG so it matches civilytics_logo()-branded plots
ragg::agg_png("table.png", width = 8, height = 4, units = "in", res = 200)
plot(flextable::flextable(head(mtcars)))
dev.off()
stamp_logo_png("table.png") # wordmark, bottom-right
stamp_logo_png("table.png", type = "mark", position = "bottom-left")
}
}
+92
View File
@@ -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.
}
+89
View File
@@ -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])
})
+30
View File
@@ -0,0 +1,30 @@
test_that("stamp_logo_png preserves image dimensions and returns the path invisibly", {
p <- tempfile(fileext = ".png")
on.exit(unlink(p), add = TRUE)
grDevices::png(p, width = 600, height = 240); plot(1:5); grDevices::dev.off()
in_dim <- dim(png::readPNG(p))[1:2]
expect_invisible(out <- stamp_logo_png(p))
expect_identical(out, p)
expect_identical(dim(png::readPNG(p))[1:2], in_dim) # overwritten at same size
})
test_that("stamp_logo_png runs for every type/variant/position combination", {
p <- tempfile(fileext = ".png")
on.exit(unlink(p), add = TRUE)
grDevices::png(p, width = 500, height = 200); plot(1:5); grDevices::dev.off()
for (ty in c("wordmark", "mark")) {
for (va in c("light", "dark")) {
for (pos in c("bottom-right", "bottom-left", "top-right", "top-left")) {
expect_no_error(stamp_logo_png(p, type = ty, variant = va, position = pos))
}
}
}
})
test_that("stamp_logo_png validates its inputs", {
expect_error(stamp_logo_png(tempfile(fileext = ".png"))) # file does not exist
# invalid type is rejected by match.arg() before the file is touched
expect_error(stamp_logo_png(tempfile(fileext = ".png"), type = "banner"))
})
+56
View File
@@ -0,0 +1,56 @@
# Test beep / notification function
context("Test beep() function")
test_that("beep() returns invisibly", {
expect_invisible(beep())
expect_invisible(beep("test", type = "beep"))
expect_invisible(beep("test", type = "all"))
})
test_that("beep() with quiet=TRUE produces no output", {
expect_silent(beep("test", type = "beep", quiet = TRUE))
})
test_that("beep() with type=notify is silent when no tools available", {
# When notify-send and osascript are both absent, should be silent
expect_silent(beep("test", type = "notify"))
})
test_that("beep() with type=webhook and no URL is silent", {
expect_silent(beep("test", type = "webhook"))
})
test_that("beep(type='all') exercises both beep and notify paths", {
# type="all" should trigger the terminal beep (unless quiet) and
# attempt a desktop notification. We verify by checking that the
# function returns invisibly and does not error when notify-send
# is absent (which is the case in CI).
expect_invisible(beep("all-test", type = "all"))
expect_invisible(beep("all-test", type = "all", quiet = TRUE))
})
test_that("notification helpers use system2() to prevent command injection", {
# Security regression: .send_webhook and .send_desktop_notify must
# use system2() with separate args, never system() with shell
# interpolation. system2() passes arguments directly to the
# executable without going through a shell, so shell metacharacters
# in msg, status, url, or payload are treated as literal data.
webhook_body <- deparse(body(.send_webhook))
notify_body <- deparse(body(.send_desktop_notify))
# Must use system2
expect_true(any(grepl("system2", webhook_body)),
info = ".send_webhook() must use system2() to avoid shell injection")
expect_true(any(grepl("system2", notify_body)),
info = ".send_desktop_notify() must use system2() to avoid shell injection")
# Must NOT use system( with string interpolation (system("curl ..."))
# We look for system( followed by a string literal (the old pattern).
# system2 calls look like system2("curl", args = ...) which is fine.
expect_false(any(grepl('system\\(\\s*"', webhook_body)),
info = ".send_webhook() must not use system() with interpolated strings")
expect_false(any(grepl('system\\(\\s*"', notify_body)),
info = ".send_desktop_notify() must not use system() with interpolated strings")
})
+6
View File
@@ -58,6 +58,12 @@ test_that("na_sum fails with non-numerics", {
expect_error(na_sum(as.factor(1:10))) expect_error(na_sum(as.factor(1:10)))
}) })
test_that("na_sum quiet=TRUE suppresses the message", {
expect_message(na_sum(c(1:10, NA)), "Taking a sum")
expect_silent(na_sum(c(1:10, NA), quiet = TRUE))
expect_equal(na_sum(c(1:10, NA), quiet = TRUE), 55)
})
context("Test Utilities - Pretty Count") context("Test Utilities - Pretty Count")