From 5fc8155965c72b3f0b240c11c5a8381ed7381385 Mon Sep 17 00:00:00 2001 From: Jared Knowles Date: Mon, 22 Jun 2026 16:33:08 -0400 Subject: [PATCH] feat(logo): add stamp_logo_png() to brand file-based PNG outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- NAMESPACE | 5 +++ R/logo.R | 89 ++++++++++++++++++++++++++++++++++++++ man/stamp_logo_png.Rd | 56 ++++++++++++++++++++++++ tests/testthat/test_logo.R | 30 +++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 man/stamp_logo_png.Rd create mode 100644 tests/testthat/test_logo.R diff --git a/NAMESPACE b/NAMESPACE index 697ed47..f562c08 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -42,6 +42,7 @@ export(safe_ratio) export(scale_color_civilytics) export(scale_fill_civilytics) export(simpleCap) +export(stamp_logo_png) export(star_subs) export(theme_civilytics) export(theme_civilytics_dark) @@ -61,8 +62,12 @@ importFrom(ggplot2,annotation_custom) importFrom(ggplot2,ggplot) importFrom(ggplot2,theme) importFrom(ggplot2,theme_void) +importFrom(grDevices,dev.off) +importFrom(grDevices,png) importFrom(graphics,rasterImage) importFrom(grid,grid.draw) +importFrom(grid,grid.newpage) +importFrom(grid,grid.raster) importFrom(grid,rasterGrob) importFrom(gridExtra,arrangeGrob) importFrom(jpeg,readJPEG) diff --git a/R/logo.R b/R/logo.R index 11b44c8..207010c 100644 --- a/R/logo.R +++ b/R/logo.R @@ -361,3 +361,92 @@ civilytics_logo <- function(plot, 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) +} diff --git a/man/stamp_logo_png.Rd b/man/stamp_logo_png.Rd new file mode 100644 index 0000000..96ac5c6 --- /dev/null +++ b/man/stamp_logo_png.Rd @@ -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") +} +} diff --git a/tests/testthat/test_logo.R b/tests/testthat/test_logo.R new file mode 100644 index 0000000..fa7cf08 --- /dev/null +++ b/tests/testthat/test_logo.R @@ -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")) +}) -- 2.54.0