diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 0e21f77..b28aa8e 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -57,4 +57,4 @@ jobs: - uses: r-lib/actions/check-r-package@v2 with: upload-snapshots: true - build_args: 'c("--no-manual","--compact-vignettes=gs+qpdf")' \ No newline at end of file + build_args: 'c("--no-manual","--compact-vignettes=gs+qpdf")' diff --git a/DESCRIPTION b/DESCRIPTION index 37b1a02..9448222 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -32,6 +32,7 @@ Imports: methods Suggests: flextable (>= 0.8.0), + jsonlite, ggplot2, gridGraphics, gt (>= 0.11.0), diff --git a/NEWS.md b/NEWS.md index 5b93988..c309b49 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,18 @@ # gridify 0.8.1.9000 +## New features + +* `export_to()` gains a `metadata` argument that records effective cell text + values, including layout defaults and values supplied via `set_cell()`, + alongside the exported output. The default is `metadata = "none"`; pass + `"sidecar"` to write a JSON sidecar `.json` next to the output. + The sidecar identifies itself as `gridify.sidecar.metadata` and uses a + schema-versioned `pages` structure for both single-page and multi-page exports. + Re-exporting the same output without metadata, or with no effective cell text, + removes any stale sidecar for that output. + The default can be changed project-wide by setting + `options(gridify.export.metadata = "sidecar")`. + # gridify 0.8.1 ## New features @@ -20,7 +33,8 @@ ## Bug fixes -* When `fill_empty` in the `paginate_table()` function is a character value, the final paginated table now coerces columns to character before filling empty cells (#20). +* When `fill_empty` in the `paginate_table()` function is a character value, + the final paginated table now coerces columns to character before filling empty cells (#20). ## Miscellaneous diff --git a/R/gridify-methods.R b/R/gridify-methods.R index 7aa23cd..6efe017 100644 --- a/R/gridify-methods.R +++ b/R/gridify-methods.R @@ -949,6 +949,25 @@ setMethod("show", "gridifyLayout", function(object) { #' The extension determines the output format. #' @param device a function for graphics device. #' By default a file name extension is used to choose a graphics device function. Default `NULL` +#' @param metadata Controls writing of metadata derived from effective cell text +#' values, including layout defaults and values supplied via [set_cell()]. +#' One of: +#' \itemize{ +#' \item `"sidecar"` - write a JSON sidecar file next to the output named `.json` +#' containing `schema`, `schema_version` and `pages`. The `schema` value is +#' `"gridify.sidecar.metadata"`. Each page contains a `cells` object mapping +#' cell names to their text values. Single-page and multi-page exports use the +#' same structure; multi-page PDFs contain one page entry per exported object. +#' Any stale sidecar is removed when no effective cell text exists. +#' \item `"none"` (default) - do not produce any metadata and remove any existing +#' sidecar for the same output file. +#' } +#' Validated with [match.arg()] so it can be abbreviated. +#' When `metadata = NULL` (the default), the value is taken from the +#' `gridify.export.metadata` global option (see [options()]), falling back to +#' `"none"` if unset. This makes it possible to enable the feature globally +#' for a project via +#' `options(gridify.export.metadata = "sidecar")`. #' @param ... Additional arguments passed to the graphics device functions #' (`pdf()`, `png()`, `tiff()`, `jpeg()` or your custom one). #' Default width and height for each export type, respectively: @@ -1124,17 +1143,25 @@ setMethod("show", "gridifyLayout", function(object) { #' ) #' #' @export -setGeneric("export_to", function(x, to, device = NULL, ...) { - standardGeneric("export_to") -}) +setGeneric( + "export_to", + function(x, to, device = NULL, metadata = NULL, ...) { + standardGeneric("export_to") + } +) #' @rdname export_to #' @export -setMethod("export_to", "gridifyClass", function(x, to, device = NULL, ...) { +setMethod( + "export_to", + "gridifyClass", + function(x, to, device = NULL, metadata = NULL, ...) { if (!(length(to) == 1 && is.character(to))) { stop("`to` must be a single string (file path) for single gridify object.") } + metadata <- resolve_export_metadata(metadata) + dir_name <- dirname(to) if (!(dir.exists(dir_name))) { stop(sprintf( @@ -1159,6 +1186,12 @@ setMethod("export_to", "gridifyClass", function(x, to, device = NULL, ...) { } user_args <- list(...) + payload <- if (metadata == "none") NULL else gridify_metadata(x) + sidecar_json <- if (metadata == "sidecar" && has_metadata_payload(payload)) { + gridify_to_json(metadata_sidecar_payload(payload)) + } else { + NULL + } if (ext %in% c("pdf")) { default_args <- list(width = 11.69, height = 8.27) @@ -1170,8 +1203,9 @@ setMethod("export_to", "gridifyClass", function(x, to, device = NULL, ...) { } do.call(device, dev_args) + on.exit(grDevices::dev.off(), add = TRUE) print(x) - on.exit(grDevices::dev.off()) + sync_metadata_sidecar(to, sidecar_json) } else if (ext %in% c("png", "jpeg", "jpg", "tiff", "tif")) { default_args <- list(width = 600, height = 400) dev_args <- utils::modifyList(default_args, user_args) @@ -1190,21 +1224,26 @@ setMethod("export_to", "gridifyClass", function(x, to, device = NULL, ...) { device <- dev_func } do.call(device, dev_args) + on.exit(grDevices::dev.off(), add = TRUE) grid::grid.newpage() print(x) - on.exit(grDevices::dev.off()) + sync_metadata_sidecar(to, sidecar_json) } }) #' @rdname export_to #' @export -setMethod("export_to", "list", function(x, to, device = NULL, ...) { +setMethod( + "export_to", + "list", + function(x, to, device = NULL, metadata = NULL, ...) { if ( !all(vapply(x, function(elem) inherits(elem, "gridifyClass"), logical(1))) ) { stop("All elements of the list must be 'gridifyClass' objects.") } + metadata <- resolve_export_metadata(metadata) to_dirs <- dirname(to) dir_exists <- dir.exists(to_dirs) @@ -1239,18 +1278,30 @@ setMethod("export_to", "list", function(x, to, device = NULL, ...) { device <- grDevices::pdf } - do.call( - device, - utils::modifyList( - list(file = to, width = 11.69, height = 8.27, onefile = TRUE), - list(...) - ) + payload <- if (metadata == "none") { + NULL + } else { + lapply(x, gridify_metadata) + } + sidecar_json <- if (metadata == "sidecar" && has_metadata_payload(payload)) { + gridify_to_json(metadata_sidecar_payload(payload)) + } else { + NULL + } + + user_args <- list(...) + dev_args <- utils::modifyList( + list(file = to, width = 11.69, height = 8.27, onefile = TRUE), + user_args ) + do.call(device, dev_args) on.exit(grDevices::dev.off(), add = TRUE) for (obj in x) { print(obj) } + + sync_metadata_sidecar(to, sidecar_json) } else { stop( "For a list of gridify objects and a single file path, the `to` extension has to be pdf." @@ -1259,7 +1310,7 @@ setMethod("export_to", "list", function(x, to, device = NULL, ...) { } else if (length(to) == length(x)) { # Each plot goes to a separate file path in `to` for (i in seq_along(x)) { - export_to(x[[i]], to[[i]], ...) + export_to(x[[i]], to[[i]], device = device, metadata = metadata, ...) } } else { stop( @@ -1271,7 +1322,7 @@ setMethod("export_to", "list", function(x, to, device = NULL, ...) { #' @rdname export_to #' @export -setMethod("export_to", "ANY", function(x, to, ...) { +setMethod("export_to", "ANY", function(x, to, device = NULL, metadata = NULL, ...) { stop( "export_to is supported for gridifyClass or list of gridifyClass objects." ) diff --git a/R/gridify-utils.R b/R/gridify-utils.R index ee17f91..31baa71 100644 --- a/R/gridify-utils.R +++ b/R/gridify-utils.R @@ -48,6 +48,131 @@ gpar_call <- function(gpar) { as.call(c(quote(grid::gpar), gpar_args(gpar))) } +#' Build the metadata payload for a `gridifyClass` object +#' +#' Extracts the effective text for each layout cell. Values set with +#' [set_cell()] take precedence over layout default text. Cells with no +#' effective text are skipped. +#' @param x a `gridifyClass` object. +#' @return a named list mapping cell name to its text value. +#' @keywords internal +gridify_metadata <- function(x) { + cells <- x@layout@cells@cells + if (length(cells) == 0) { + return(stats::setNames(list(), character(0))) + } + texts <- lapply(names(cells), function(cell) { + elem <- x@elements[[cell]] + cell_info <- cells[[cell]] + candidates <- c(elem[["text"]], cell_info@text) + if (length(candidates) == 0) NULL else candidates[1] + }) + names(texts) <- names(cells) + texts[!vapply(texts, is.null, logical(1))] +} + +#' Encode a metadata payload as JSON via `jsonlite`. +#' +#' Thin wrapper around `jsonlite::toJSON()` with the options used by gridify +#' metadata: scalar character/numeric/logical values are unboxed, `NA` and +#' `NULL` are serialised as `null`. Centralised so the encoder options live in +#' one place. +#' @param x value to encode. +#' @return a length-one character vector with the JSON representation of `x`. +#' @keywords internal +gridify_to_json <- function(x) { + if (requireNamespace("jsonlite", quietly = TRUE)) { + as.character(jsonlite::toJSON( + x, + auto_unbox = TRUE, + null = "null", + na = "null" + )) + } else { + stop("Please install the 'jsonlite' package to use the gridify_to_json function") + } +} + +#' Build the JSON sidecar metadata structure +#' +#' Wraps single-page and multi-page metadata in the same schema so consumers can +#' always read metadata from `pages[[i]]$cells`. +#' +#' @param payload A named list (single page) or list of named lists (multi-page) +#' of metadata values. +#' @return A named list containing `schema`, `schema_version` and `pages`. +#' @keywords internal +metadata_sidecar_payload <- function(payload) { + pages <- if (is.list(payload) && is.null(names(payload))) { + payload + } else { + list(payload) + } + + list( + schema = "gridify.sidecar.metadata", + schema_version = "1.0.0", + pages = lapply(pages, function(cells) list(cells = cells)) + ) +} + +#' Check whether a metadata payload contains values +#' +#' @param payload A metadata payload. +#' @return `TRUE` when the payload contains at least one metadata value. +#' @keywords internal +has_metadata_payload <- function(payload) { + if (is.null(payload) || length(payload) == 0) { + return(FALSE) + } + if (is.list(payload) && is.null(names(payload))) { + return(any(vapply(payload, has_metadata_payload, logical(1)))) + } + TRUE +} + +#' Synchronise the JSON metadata sidecar file +#' +#' Writes `json` to the sidecar when supplied. Otherwise removes any existing +#' sidecar for `to`, preventing stale metadata from surviving later exports of +#' the same output file. +#' +#' @param to A length-one character string with the path of the main output +#' file. +#' @param json Optional pre-encoded JSON metadata. +#' @return Invisibly, the path of the sidecar file that was written or removed. +#' @keywords internal +sync_metadata_sidecar <- function(to, json = NULL) { + side <- paste0(to, ".json") + if (!is.null(json)) { + writeLines(json, con = side, useBytes = TRUE) + } else if (file.exists(side)) { + unlink(side) + } + invisible(side) +} + +#' Resolve the effective `metadata` argument for `export_to()` +#' +#' Resolves the `metadata` argument from (in order of precedence): +#' 1. the value passed by the caller, +#' 2. the `gridify.export.metadata` global option, +#' 3. the built-in default `"none"`. +#' +#' The result is then validated against the allowed choices via +#' [match.arg()], so abbreviations are accepted. +#' +#' @param metadata the value passed by the user; may be `NULL`. +#' @return one of `"none"`, `"sidecar"`. +#' @keywords internal +resolve_export_metadata <- function(metadata) { + choices <- c("none", "sidecar") + if (is.null(metadata)) { + metadata <- getOption("gridify.export.metadata", "none") + } + match.arg(metadata, choices) +} + #' Detect a "flexible" grob whose natural height is not meaningful #' #' A flexible grob is one designed to fill whatever container it is placed diff --git a/man/export_to.Rd b/man/export_to.Rd index b78d105..1e7e3b4 100644 --- a/man/export_to.Rd +++ b/man/export_to.Rd @@ -7,13 +7,13 @@ \alias{export_to,ANY-method} \title{Export gridify objects to a file} \usage{ -export_to(x, to, device = NULL, ...) +export_to(x, to, device = NULL, metadata = NULL, ...) -\S4method{export_to}{gridifyClass}(x, to, device = NULL, ...) +\S4method{export_to}{gridifyClass}(x, to, device = NULL, metadata = NULL, ...) -\S4method{export_to}{list}(x, to, device = NULL, ...) +\S4method{export_to}{list}(x, to, device = NULL, metadata = NULL, ...) -\S4method{export_to}{ANY}(x, to, device = NULL, ...) +\S4method{export_to}{ANY}(x, to, device = NULL, metadata = NULL, ...) } \arguments{ \item{x}{A \code{gridifyClass} object or a list of \code{gridifyClass} objects.} @@ -24,6 +24,26 @@ The extension determines the output format.} \item{device}{a function for graphics device. By default a file name extension is used to choose a graphics device function. Default \code{NULL}} +\item{metadata}{Controls writing of metadata derived from effective cell text +values, including layout defaults and values supplied via \code{\link[=set_cell]{set_cell()}}. +One of: +\itemize{ +\item \code{"sidecar"} - write a JSON sidecar file next to the output named \verb{.json} +containing \code{schema}, \code{schema_version} and \code{pages}. The \code{schema} value is +\code{"gridify.sidecar.metadata"}. Each page contains a \code{cells} object mapping +cell names to their text values. Single-page and multi-page exports use the +same structure; multi-page PDFs contain one page entry per exported object. +Any stale sidecar is removed when no effective cell text exists. +\item \code{"none"} (default) - do not produce any metadata and remove any existing +sidecar for the same output file. +} +Validated with \code{\link[=match.arg]{match.arg()}} so it can be abbreviated. +When \code{metadata = NULL} (the default), the value is taken from the +\code{gridify.export.metadata} global option (see \code{\link[=options]{options()}}), falling back to +\code{"none"} if unset. This makes it possible to enable the feature globally +for a project via +\code{options(gridify.export.metadata = "sidecar")}.} + \item{...}{Additional arguments passed to the graphics device functions (\code{pdf()}, \code{png()}, \code{tiff()}, \code{jpeg()} or your custom one). Default width and height for each export type, respectively: diff --git a/man/gridify_metadata.Rd b/man/gridify_metadata.Rd new file mode 100644 index 0000000..1828e71 --- /dev/null +++ b/man/gridify_metadata.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gridify-utils.R +\name{gridify_metadata} +\alias{gridify_metadata} +\title{Build the metadata payload for a \code{gridifyClass} object} +\usage{ +gridify_metadata(x) +} +\arguments{ +\item{x}{a \code{gridifyClass} object.} +} +\value{ +a named list mapping cell name to its text value. +} +\description{ +Extracts the effective text for each layout cell. Values set with +\code{\link[=set_cell]{set_cell()}} take precedence over layout default text. Cells with no +effective text are skipped. +} +\keyword{internal} diff --git a/man/gridify_to_json.Rd b/man/gridify_to_json.Rd new file mode 100644 index 0000000..608d69a --- /dev/null +++ b/man/gridify_to_json.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gridify-utils.R +\name{gridify_to_json} +\alias{gridify_to_json} +\title{Encode a metadata payload as JSON via \code{jsonlite}.} +\usage{ +gridify_to_json(x) +} +\arguments{ +\item{x}{value to encode.} +} +\value{ +a length-one character vector with the JSON representation of \code{x}. +} +\description{ +Thin wrapper around \code{jsonlite::toJSON()} with the options used by gridify +metadata: scalar character/numeric/logical values are unboxed, \code{NA} and +\code{NULL} are serialised as \code{null}. Centralised so the encoder options live in +one place. +} +\keyword{internal} diff --git a/man/has_metadata_payload.Rd b/man/has_metadata_payload.Rd new file mode 100644 index 0000000..2372514 --- /dev/null +++ b/man/has_metadata_payload.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gridify-utils.R +\name{has_metadata_payload} +\alias{has_metadata_payload} +\title{Check whether a metadata payload contains values} +\usage{ +has_metadata_payload(payload) +} +\arguments{ +\item{payload}{A metadata payload.} +} +\value{ +\code{TRUE} when the payload contains at least one metadata value. +} +\description{ +Check whether a metadata payload contains values +} +\keyword{internal} diff --git a/man/metadata_sidecar_payload.Rd b/man/metadata_sidecar_payload.Rd new file mode 100644 index 0000000..975d0be --- /dev/null +++ b/man/metadata_sidecar_payload.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gridify-utils.R +\name{metadata_sidecar_payload} +\alias{metadata_sidecar_payload} +\title{Build the JSON sidecar metadata structure} +\usage{ +metadata_sidecar_payload(payload) +} +\arguments{ +\item{payload}{A named list (single page) or list of named lists (multi-page) +of metadata values.} +} +\value{ +A named list containing \code{schema}, \code{schema_version} and \code{pages}. +} +\description{ +Wraps single-page and multi-page metadata in the same schema so consumers can +always read metadata from \code{pages[[i]]$cells}. +} +\keyword{internal} diff --git a/man/resolve_export_metadata.Rd b/man/resolve_export_metadata.Rd new file mode 100644 index 0000000..6e8a067 --- /dev/null +++ b/man/resolve_export_metadata.Rd @@ -0,0 +1,27 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gridify-utils.R +\name{resolve_export_metadata} +\alias{resolve_export_metadata} +\title{Resolve the effective \code{metadata} argument for \code{export_to()}} +\usage{ +resolve_export_metadata(metadata) +} +\arguments{ +\item{metadata}{the value passed by the user; may be \code{NULL}.} +} +\value{ +one of \code{"none"}, \code{"sidecar"}. +} +\description{ +Resolves the \code{metadata} argument from (in order of precedence): +\enumerate{ +\item the value passed by the caller, +\item the \code{gridify.export.metadata} global option, +\item the built-in default \code{"none"}. +} +} +\details{ +The result is then validated against the allowed choices via +\code{\link[=match.arg]{match.arg()}}, so abbreviations are accepted. +} +\keyword{internal} diff --git a/man/sync_metadata_sidecar.Rd b/man/sync_metadata_sidecar.Rd new file mode 100644 index 0000000..a3e05ef --- /dev/null +++ b/man/sync_metadata_sidecar.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gridify-utils.R +\name{sync_metadata_sidecar} +\alias{sync_metadata_sidecar} +\title{Synchronise the JSON metadata sidecar file} +\usage{ +sync_metadata_sidecar(to, json = NULL) +} +\arguments{ +\item{to}{A length-one character string with the path of the main output +file.} + +\item{json}{Optional pre-encoded JSON metadata.} +} +\value{ +Invisibly, the path of the sidecar file that was written or removed. +} +\description{ +Writes \code{json} to the sidecar when supplied. Otherwise removes any existing +sidecar for \code{to}, preventing stale metadata from surviving later exports of +the same output file. +} +\keyword{internal} diff --git a/tests/testthat/test_export_to.R b/tests/testthat/test_export_to.R index 1effbe8..7305d40 100644 --- a/tests/testthat/test_export_to.R +++ b/tests/testthat/test_export_to.R @@ -191,3 +191,194 @@ test_that("length(to) == length(x) check", { "`to` must be either a single pdf file path or a character vector matching the length of `x`." ) }) + +mock_gridify_with_cells <- function() { + grb <- grid::rectGrob() + obj <- gridify(grb, pharma_layout_base()) + obj <- set_cell(obj, "header_left_1", "My Company") + obj <- set_cell(obj, "title_1", "") + obj <- set_cell(obj, "watermark", "DRAFT \"x\" \\ y\nz") + obj +} + +test_that("default metadata writes no sidecar (option unset)", { + old <- options(gridify.export.metadata = NULL) + on.exit(options(old), add = TRUE) + x <- mock_gridify_with_cells() + out_file <- file.path(tempdir(), "meta_default.pdf") + side <- paste0(out_file, ".json") + if (file.exists(side)) file.remove(side) + + expect_no_error(export_to(x, out_file)) + expect_true(file.exists(out_file)) + expect_false(file.exists(side)) +}) + +test_that("metadata = 'sidecar' writes JSON sidecar for PDF and PNG", { + skip_if_not_installed("jsonlite") + x <- mock_gridify_with_cells() + + for (ext in c("pdf", "png")) { + out_file <- file.path(tempdir(), paste0("meta_single.", ext)) + side <- paste0(out_file, ".json") + if (file.exists(side)) file.remove(side) + + expect_no_error(export_to(x, out_file, metadata = "sidecar")) + expect_true(file.exists(out_file)) + expect_true(file.exists(side)) + + parsed <- jsonlite::fromJSON(side, simplifyVector = FALSE) + expect_identical(parsed$schema, "gridify.sidecar.metadata") + expect_identical(parsed$schema_version, "1.0.0") + expect_length(parsed$pages, 1) + expect_identical(parsed$pages[[1]]$cells$header_left_1, "My Company") + expect_identical(parsed$pages[[1]]$cells$title_1, "<Title 1>") + expect_identical(parsed$pages[[1]]$cells$watermark, "DRAFT \"x\" \\ y\nz") + } +}) + +test_that("gridify.export.metadata option provides the default", { + old <- options(gridify.export.metadata = "sidecar") + on.exit(options(old), add = TRUE) + x <- mock_gridify_with_cells() + out_file <- file.path(tempdir(), "meta_option.pdf") + side <- paste0(out_file, ".json") + if (file.exists(side)) file.remove(side) + + expect_no_error(export_to(x, out_file)) + expect_true(file.exists(side)) + + # Explicit argument still beats the option + if (file.exists(side)) file.remove(side) + expect_no_error(export_to(x, out_file, metadata = "none")) + expect_false(file.exists(side)) +}) + +test_that("gridify.export.metadata option accepts abbreviations", { + old <- options(gridify.export.metadata = "s") + on.exit(options(old), add = TRUE) + x <- mock_gridify_with_cells() + out_file <- file.path(tempdir(), "meta_option_abbr.pdf") + side <- paste0(out_file, ".json") + if (file.exists(side)) file.remove(side) + + expect_no_error(export_to(x, out_file)) + expect_true(file.exists(side)) +}) + +test_that("gridify.export.metadata invalid option value is rejected", { + old <- options(gridify.export.metadata = "yes") + on.exit(options(old), add = TRUE) + x <- mock_gridify_with_cells() + expect_error( + export_to(x, file.path(tempdir(), "bad.pdf")), + "should be one of" + ) +}) + +test_that("metadata = 'none' writes no sidecar", { + x <- mock_gridify_with_cells() + out_file <- file.path(tempdir(), "meta_off.pdf") + side <- paste0(out_file, ".json") + if (file.exists(side)) file.remove(side) + + expect_no_error(export_to(x, out_file, metadata = "none")) + expect_true(file.exists(out_file)) + expect_false(file.exists(side)) +}) + +test_that("metadata sidecar includes layout default text", { + skip_if_not_installed("jsonlite") + x <- gridify(grid::rectGrob(), pharma_layout_base()) + out_file <- file.path(tempdir(), "meta_defaults.pdf") + side <- paste0(out_file, ".json") + if (file.exists(side)) file.remove(side) + + expect_no_error(export_to(x, out_file, metadata = "sidecar")) + expect_true(file.exists(out_file)) + expect_true(file.exists(side)) + + parsed <- jsonlite::fromJSON(side, simplifyVector = FALSE) + expect_identical(parsed$pages[[1]]$cells, list(header_right_1 = "CONFIDENTIAL")) +}) + +test_that("metadata = 'none' removes stale sidecar", { + x <- mock_gridify_with_cells() + out_file <- file.path(tempdir(), "meta_stale_removed.pdf") + side <- paste0(out_file, ".json") + if (file.exists(side)) file.remove(side) + + expect_no_error(export_to(x, out_file, metadata = "sidecar")) + expect_true(file.exists(side)) + + expect_no_error(export_to(x, out_file, metadata = "none")) + expect_true(file.exists(out_file)) + expect_false(file.exists(side)) +}) + +test_that("metadata writes no sidecar when no cells are set", { + x <- mock_gridify() + out_file <- file.path(tempdir(), "meta_empty.pdf") + side <- paste0(out_file, ".json") + if (file.exists(side)) file.remove(side) + + expect_no_error(export_to(x, out_file, metadata = "sidecar")) + expect_true(file.exists(out_file)) + expect_false(file.exists(side)) +}) + +test_that("empty metadata removes stale sidecar", { + out_file <- file.path(tempdir(), "meta_empty_removes_stale.pdf") + side <- paste0(out_file, ".json") + if (file.exists(side)) file.remove(side) + + expect_no_error(export_to(mock_gridify_with_cells(), out_file, metadata = "sidecar")) + expect_true(file.exists(side)) + + expect_no_error(export_to(mock_gridify(), out_file, metadata = "sidecar")) + expect_true(file.exists(out_file)) + expect_false(file.exists(side)) +}) + +test_that("metadata sidecar for multi-page PDF uses pages schema", { + skip_if_not_installed("jsonlite") + x_list <- list(mock_gridify_with_cells(), mock_gridify_with_cells()) + out_file <- file.path(tempdir(), "meta_multi.pdf") + side <- paste0(out_file, ".json") + if (file.exists(side)) file.remove(side) + + expect_no_error(export_to(x_list, out_file, metadata = "sidecar")) + expect_true(file.exists(side)) + + parsed <- jsonlite::fromJSON(side, simplifyVector = FALSE) + expect_identical(parsed$schema, "gridify.sidecar.metadata") + expect_identical(parsed$schema_version, "1.0.0") + expect_length(parsed$pages, 2) + expect_identical(parsed$pages[[1]]$cells$header_left_1, "My Company") + expect_identical(parsed$pages[[2]]$cells$header_left_1, "My Company") +}) + +test_that("metadata can be abbreviated via match.arg", { + x <- mock_gridify_with_cells() + out_file <- file.path(tempdir(), "meta_abbr.pdf") + side <- paste0(out_file, ".json") + if (file.exists(side)) file.remove(side) + + expect_no_error(export_to(x, out_file, metadata = "s")) + expect_true(file.exists(side)) +}) + +test_that("metadata invalid values are rejected", { + x <- mock_gridify() + expect_error( + export_to(x, file.path(tempdir(), "bad.pdf"), metadata = "yes"), + "should be one of" + ) + expect_error( + export_to( + list(x, x), + file.path(tempdir(), "bad.pdf"), + metadata = 1 + ) + ) +}) diff --git a/tests/testthat/test_gridify_to_json.R b/tests/testthat/test_gridify_to_json.R new file mode 100644 index 0000000..8706a53 --- /dev/null +++ b/tests/testthat/test_gridify_to_json.R @@ -0,0 +1,128 @@ +test_that("gridify_to_json round-trips basic payloads", { + skip_if_not_installed("jsonlite") + expect_identical( + jsonlite::fromJSON(gridify_to_json(list(a = "x", b = "y"))), + list(a = "x", b = "y") + ) + expect_identical( + jsonlite::fromJSON(gridify_to_json(list())), + list() + ) +}) + +test_that("gridify_to_json unboxes scalars", { + json <- gridify_to_json(list(a = "x", n = 1)) + expect_match(json, "\"a\":\"x\"", fixed = TRUE) + expect_match(json, "\"n\":1", fixed = TRUE) +}) + +test_that("gridify_to_json escapes special characters", { + skip_if_not_installed("jsonlite") + s <- "DRAFT \"x\" \\ y\nz" + json <- gridify_to_json(list(w = s)) + expect_identical(jsonlite::fromJSON(json)$w, s) +}) + +test_that("gridify_metadata extracts effective cell text values", { + obj <- gridify(grid::rectGrob(), pharma_layout_base()) + obj <- set_cell(obj, "header_left_1", "Co") + obj <- set_cell(obj, "header_right_1", "Not confidential") + obj <- set_cell(obj, "title_1", "T1") + meta <- gridify_metadata(obj) + expect_identical( + meta, + list( + header_left_1 = "Co", + header_right_1 = "Not confidential", + title_1 = "T1" + ) + ) +}) + +test_that("gridify_metadata includes layout default text values", { + obj <- gridify(grid::rectGrob(), pharma_layout_base()) + expect_identical(gridify_metadata(obj), list(header_right_1 = "CONFIDENTIAL")) +}) + +test_that("gridify_metadata returns empty list when no effective text exists", { + obj <- gridify(grid::rectGrob(), simple_layout()) + expect_identical(gridify_metadata(obj), stats::setNames(list(), character(0))) +}) + +test_that("has_metadata_payload detects populated payloads", { + expect_false(has_metadata_payload(NULL)) + expect_false(has_metadata_payload(list())) + expect_false(has_metadata_payload(list(list(), list()))) + expect_true(has_metadata_payload(list(a = "x"))) + expect_true(has_metadata_payload(list(list(), list(a = "x")))) +}) + +test_that("metadata_sidecar_payload uses a uniform pages schema", { + single <- metadata_sidecar_payload(list(a = "x")) + expect_identical(single$schema, "gridify.sidecar.metadata") + expect_identical(single$schema_version, "1.0.0") + expect_length(single$pages, 1) + expect_identical(single$pages[[1]]$cells, list(a = "x")) + + multi <- metadata_sidecar_payload(list(list(a = "1"), list(a = "2"))) + expect_identical(multi$schema, "gridify.sidecar.metadata") + expect_identical(multi$schema_version, "1.0.0") + expect_length(multi$pages, 2) + expect_identical(multi$pages[[1]]$cells, list(a = "1")) + expect_identical(multi$pages[[2]]$cells, list(a = "2")) +}) + +test_that("sync_metadata_sidecar writes populated sidecars", { + skip_if_not_installed("jsonlite") + base <- tempfile(fileext = ".pdf") + side <- paste0(base, ".json") + if (file.exists(side)) file.remove(side) + json <- gridify_to_json(metadata_sidecar_payload(list(a = "x"))) + + expect_identical(sync_metadata_sidecar(base, json), side) + expect_true(file.exists(side)) + parsed <- jsonlite::fromJSON(side, simplifyVector = FALSE) + expect_identical(parsed$schema, "gridify.sidecar.metadata") + expect_identical(parsed$schema_version, "1.0.0") + expect_identical(parsed$pages[[1]]$cells$a, "x") +}) + +test_that("sync_metadata_sidecar serialises multi-page list payload", { + skip_if_not_installed("jsonlite") + base <- tempfile(fileext = ".pdf") + side <- paste0(base, ".json") + if (file.exists(side)) file.remove(side) + + payload <- list(list(a = "1"), list(a = "2")) + sync_metadata_sidecar(base, gridify_to_json(metadata_sidecar_payload(payload))) + + parsed <- jsonlite::fromJSON(side, simplifyVector = FALSE) + expect_identical(parsed$schema, "gridify.sidecar.metadata") + expect_identical(parsed$schema_version, "1.0.0") + expect_length(parsed$pages, 2) + expect_identical(parsed$pages[[1]]$cells$a, "1") + expect_identical(parsed$pages[[2]]$cells$a, "2") +}) + +test_that("sync_metadata_sidecar uses pre-encoded JSON", { + base <- tempfile(fileext = ".pdf") + side <- paste0(base, ".json") + if (file.exists(side)) file.remove(side) + + expect_identical(sync_metadata_sidecar(base, "{\"a\":\"y\"}"), side) + expect_identical(readLines(side, warn = FALSE), "{\"a\":\"y\"}") +}) + +test_that("sync_metadata_sidecar removes stale files", { + base <- tempfile(fileext = ".pdf") + side <- paste0(base, ".json") + + writeLines("stale", side) + expect_identical(sync_metadata_sidecar(base), side) + expect_false(file.exists(side)) + + writeLines("stale", side) + expect_identical(sync_metadata_sidecar(base, NULL), side) + expect_false(file.exists(side)) +}) + diff --git a/vignettes/multi_page_examples.Rmd b/vignettes/multi_page_examples.Rmd index 8562cf2..cbf2baa 100644 --- a/vignettes/multi_page_examples.Rmd +++ b/vignettes/multi_page_examples.Rmd @@ -347,6 +347,49 @@ export_to( ) ``` +Passing `metadata = "sidecar"` to `export_to()` writes a JSON sidecar next to +each output file. The sidecar identifies itself as `gridify.sidecar.metadata` +and uses the same schema-versioned `pages` structure for single-page and +multi-page exports. It records effective cell text values, including layout +defaults and values supplied via `set_cell()`: + +```json +{ + "schema": "gridify.sidecar.metadata", + "schema_version": "1.0.0", + "pages": [ + { + "cells": { + "title_1": "Page 1" + } + }, + { + "cells": { + "title_1": "Page 2" + } + } + ] +} +``` + +The number of sidecars and page entries depends on how the output is structured: + +- **Single multi-page PDF** (a list of objects exported to one `.pdf` path) — + one sidecar `<file>.pdf.json` containing one `pages` entry per exported + object. +- **Multiple separate files** (a list of objects exported to a vector of paths, + or any single-object export) — one sidecar per file (e.g. `fig1.pdf.json`, + `fig2.pdf.json`, ...), each containing one `pages` entry. + +The default is `"none"` (no metadata). Set +`options(gridify.export.metadata = "sidecar")` to change the default +project-wide. + +When reusing the same output path, `export_to()` keeps the output and sidecar in +sync: exporting without metadata, or exporting an object/list with no +effective cell text values, removes any existing sidecar for that output. This +avoids leaving stale JSON metadata next to a newer PDF, PNG, TIFF or JPEG file. + ### Extending The Multi-Page Example In pharmaceutical and other industries, presenting tables often requires customization to meet reporting standards. While splitting by rows is a common approach for handling large datasets, it is not the only option. Here are some potential ways in which a table could be split up, where `gridify` could then be used to generate a new page for each table: diff --git a/vignettes/simple_examples.Rmd b/vignettes/simple_examples.Rmd index 53f4bea..aba7b6c 100644 --- a/vignettes/simple_examples.Rmd +++ b/vignettes/simple_examples.Rmd @@ -859,6 +859,53 @@ and `height` by passing them into `export_to()` after the `to` argument. export_to(gridify_obj, to = "output.jpeg", width = 2400, height = 1800, res = 300) ``` +### Metadata + +`export_to()` can optionally record effective cell text values alongside the +output, including text from layout defaults and values supplied via +`set_cell()`. This makes it easy to track which header, footer or watermark +text was used to produce a given figure without parsing the file. +The feature is opt-in via the `metadata` argument. The sidecar identifies itself +as `gridify.sidecar.metadata` and uses the same schema-versioned `pages` +structure for single-page and multi-page exports. + +```{r, eval = FALSE} +# Default: just the PDF, no metadata is written +export_to(gridify_obj, to = "output.pdf") + +# Write a JSON sidecar (output.pdf.json) next to the output +export_to(gridify_obj, to = "output.pdf", metadata = "sidecar") +``` + +For a single-page export, the JSON sidecar has one page entry: + +```json +{ + "schema": "gridify.sidecar.metadata", + "schema_version": "1.0.0", + "pages": [ + { + "cells": { + "header_left_1": "My Company", + "title_1": "<Title 1>" + } + } + ] +} +``` + +If the same output path is exported later with `metadata = "none"`, or with +`metadata = "sidecar"` but no effective cell text values, any existing sidecar +for that output is removed. This prevents an older `output.pdf.json` from being +mistaken for metadata from the latest export. + +To enable metadata writing globally for a project, set the +`gridify.export.metadata` option once (e.g. in `.Rprofile`): + +```{r, eval = FALSE} +options(gridify.export.metadata = "sidecar") +``` + ## Conclusion These examples should give you a good understanding of how to use the `gridify` package to add text