diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 722fa8e..251a652 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -29,8 +29,8 @@ jobs: - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} - {os: ubuntu-latest, r: 'release'} - - {os: ubuntu-latest, r: 'oldrel-1'} - - {os: ubuntu-latest, r: 'oldrel-2'} + # - {os: ubuntu-latest, r: 'oldrel-1'} + # - {os: ubuntu-latest, r: 'oldrel-2'} - {os: ubuntu-latest, r: 'oldrel-3'} # - {os: ubuntu-latest, r: 'oldrel-4'} @@ -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 e109ca0..44e9f22 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -24,13 +24,13 @@ URL: https://pharmaverse.github.io/gridify/ BugReports: https://github.com/pharmaverse/gridify/issues Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.3 Imports: grDevices, grid, methods Suggests: flextable (>= 0.8.0), + jsonlite, ggplot2, gridGraphics, gt (>= 0.11.0), @@ -41,7 +41,7 @@ Suggests: spelling, testthat (>= 3.0.0) Collate: - grid_utils.R + gridify-utils.R gridify-classes.R gridify-methods.R ansi_colour.R @@ -54,3 +54,4 @@ Collate: VignetteBuilder: knitr Config/testthat/edition: 3 Language: en-GB +Config/roxygen2/version: 8.0.0 diff --git a/NEWS.md b/NEWS.md index b90c988..3ac6a0a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,11 +2,22 @@ ## 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")`. * Added support for `fill_empty = NA` in the `paginate_table()` function. ## 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/grid_utils.R b/R/grid_utils.R deleted file mode 100644 index a5fb90d..0000000 --- a/R/grid_utils.R +++ /dev/null @@ -1,49 +0,0 @@ -#' Wrapper for `grid::unitType` which supports older R versions -#' @param x a grid::unit -#' @param use_grid means try to call grid::unitType if it exists. -#' The main purpose of this argument is to have full test coverage in tests. -#' Default TRUE. -#' @return a character vector with unit type for each element. -#' @keywords internal -grid_unit_type <- function(x, use_grid = TRUE) { - if (use_grid && is.function(base::asNamespace("grid")[["unitType"]])) { - utils::getFromNamespace("unitType", "grid")(x) - } else { - unit_val <- attr(x, "unit") - if (!is.null(unit_val)) { - rep(unit_val, length = length(x)) - } else { - stop("grid_unit_type x argument: Not a unit object") - } - } -} - -#' Get `grid::gpar` arguments -#' @param gpar a `grid::gpar` object. -#' @return a list. -#' @keywords internal -gpar_args <- function(gpar) { - args <- as.list(gpar) - fontface <- args[["fontface"]] - font <- if (isTRUE(is.na(args[["font"]]))) NULL else args[["font"]] - - # Remove the original font and fontface from args - args[["font"]] <- NULL - args[["fontface"]] <- NULL - - args[["fontface"]] <- if (!is.null(fontface)) fontface else font - - args -} - -#' Convert `grid::gpar` to a call -#' @param gpar a `grid::gpar` object. -#' @return a call. -#' @keywords internal -gpar_call <- function(gpar) { - if (length(gpar) == 0) { - return(as.call(c(quote(grid::gpar), list()))) - } - - as.call(c(quote(grid::gpar), gpar_args(gpar))) -} diff --git a/R/gridify-methods.R b/R/gridify-methods.R index 69790ad..c54865f 100644 --- a/R/gridify-methods.R +++ b/R/gridify-methods.R @@ -941,6 +941,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: @@ -1116,17 +1135,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( @@ -1151,6 +1178,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) @@ -1162,8 +1195,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) @@ -1182,21 +1216,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) @@ -1231,18 +1270,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." @@ -1251,7 +1302,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( @@ -1263,7 +1314,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 new file mode 100644 index 0000000..a210839 --- /dev/null +++ b/R/gridify-utils.R @@ -0,0 +1,174 @@ +#' Wrapper for `grid::unitType` which supports older R versions +#' @param x a grid::unit +#' @param use_grid means try to call grid::unitType if it exists. +#' The main purpose of this argument is to have full test coverage in tests. +#' Default TRUE. +#' @return a character vector with unit type for each element. +#' @keywords internal +grid_unit_type <- function(x, use_grid = TRUE) { + if (use_grid && is.function(base::asNamespace("grid")[["unitType"]])) { + utils::getFromNamespace("unitType", "grid")(x) + } else { + unit_val <- attr(x, "unit") + if (!is.null(unit_val)) { + rep(unit_val, length = length(x)) + } else { + stop("grid_unit_type x argument: Not a unit object") + } + } +} + +#' Get `grid::gpar` arguments +#' @param gpar a `grid::gpar` object. +#' @return a list. +#' @keywords internal +gpar_args <- function(gpar) { + args <- as.list(gpar) + fontface <- args[["fontface"]] + font <- if (isTRUE(is.na(args[["font"]]))) NULL else args[["font"]] + + # Remove the original font and fontface from args + args[["font"]] <- NULL + args[["fontface"]] <- NULL + + args[["fontface"]] <- if (!is.null(fontface)) fontface else font + + args +} + +#' Convert `grid::gpar` to a call +#' @param gpar a `grid::gpar` object. +#' @return a call. +#' @keywords internal +gpar_call <- function(gpar) { + if (length(gpar) == 0) { + return(as.call(c(quote(grid::gpar), list()))) + } + + 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) +} \ No newline at end of file diff --git a/inst/WORDLIST b/inst/WORDLIST index dcb22d7..46baa54 100644 --- a/inst/WORDLIST +++ b/inst/WORDLIST @@ -1,11 +1,9 @@ Abdy Acknowledgments Bleier -centers CMD Codecov Dallimore -doi HersheySerif Laetitia Lemoine @@ -22,6 +20,9 @@ Qmd RStudio Rmd TransactionID +Vicencio +centers +doi etc flextable fontfamily @@ -36,6 +37,7 @@ gridifying gt gtable labeled +lifecycle npc pharmaverse px @@ -43,4 +45,3 @@ rd rtables sprintf unitType -Vicencio \ No newline at end of file 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/gpar_args.Rd b/man/gpar_args.Rd index 5299e4f..4dd8b1a 100644 --- a/man/gpar_args.Rd +++ b/man/gpar_args.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/grid_utils.R +% Please edit documentation in R/gridify-utils.R \name{gpar_args} \alias{gpar_args} \title{Get \code{grid::gpar} arguments} diff --git a/man/gpar_call.Rd b/man/gpar_call.Rd index a1ebaf0..e45d20b 100644 --- a/man/gpar_call.Rd +++ b/man/gpar_call.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/grid_utils.R +% Please edit documentation in R/gridify-utils.R \name{gpar_call} \alias{gpar_call} \title{Convert \code{grid::gpar} to a call} diff --git a/man/grid_unit_type.Rd b/man/grid_unit_type.Rd index e2b0924..0c7a070 100644 --- a/man/grid_unit_type.Rd +++ b/man/grid_unit_type.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/grid_utils.R +% Please edit documentation in R/gridify-utils.R \name{grid_unit_type} \alias{grid_unit_type} \title{Wrapper for \code{grid::unitType} which supports older R versions} 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 8310087..4b5b803 100644 --- a/vignettes/simple_examples.Rmd +++ b/vignettes/simple_examples.Rmd @@ -765,6 +765,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