From 32187ba48b065ec0cdaa2fe1ff70d802da433018 Mon Sep 17 00:00:00 2001 From: Maciej Nasinski Date: Sun, 10 May 2026 22:58:04 +0200 Subject: [PATCH 01/10] metadata --- DESCRIPTION | 1 + NEWS.md | 10 +++ R/grid_utils.R | 34 ++++++++ R/gridify-methods.R | 117 ++++++++++++++++++++++---- man/export_to.Rd | 22 ++++- man/gridify_metadata.Rd | 19 +++++ man/gridify_to_json.Rd | 21 +++++ tests/testthat/test_export_to.R | 112 ++++++++++++++++++++++++ tests/testthat/test_gridify_to_json.R | 35 ++++++++ vignettes/multi_page_examples.Rmd | 5 ++ vignettes/simple_examples.Rmd | 19 +++++ 11 files changed, 376 insertions(+), 19 deletions(-) create mode 100644 man/gridify_metadata.Rd create mode 100644 man/gridify_to_json.Rd create mode 100644 tests/testthat/test_gridify_to_json.R diff --git a/DESCRIPTION b/DESCRIPTION index bbd973d..f02ef6b 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -28,6 +28,7 @@ RoxygenNote: 7.3.3 Imports: grDevices, grid, + jsonlite, methods Suggests: flextable (>= 0.8.0), diff --git a/NEWS.md b/NEWS.md index e88a154..5a555f6 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,13 @@ +# gridify 0.7.7.9000 + +* `export_to()` gains a `metadata` argument that records `set_cell()` text values + alongside the exported output. With the default `metadata = TRUE` a JSON + sidecar `.json` is written next to the output (no extra dependencies); + `metadata = "embed"` (PDF only) injects the same JSON payload as the PDF + `/Title` so the metadata travels inside the file. Set `metadata = FALSE` to + preserve the previous behaviour. + + # gridify 0.7.7 * Updated `README.md` file. diff --git a/R/grid_utils.R b/R/grid_utils.R index a5fb90d..3ec474d 100644 --- a/R/grid_utils.R +++ b/R/grid_utils.R @@ -47,3 +47,37 @@ gpar_call <- function(gpar) { as.call(c(quote(grid::gpar), gpar_args(gpar))) } + +#' Build the metadata payload for a `gridifyClass` object. +#' +#' Extracts the `text` field from each `set_cell()` element, in the order they +#' were added. Cells with `NULL` 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) { + elems <- x@elements + if (length(elems) == 0) { + return(stats::setNames(list(), character(0))) + } + texts <- lapply(elems, function(el) el[["text"]]) + 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) { + as.character(jsonlite::toJSON( + x, + auto_unbox = TRUE, + null = "null", + na = "null" + )) +} diff --git a/R/gridify-methods.R b/R/gridify-methods.R index 69790ad..5cdbd8a 100644 --- a/R/gridify-methods.R +++ b/R/gridify-methods.R @@ -941,6 +941,19 @@ 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 `set_cell()` text values. +#' One of: +#' \itemize{ +#' \item `TRUE` (default) - write a JSON sidecar file next to the output named `.json` +#' containing a named list mapping cell name to its text value (or, for a multi-page PDF +#' built from a list of objects, a JSON array of such named lists, one per page). +#' No file is written when no cells were set. +#' \item `FALSE` - do not produce any metadata. +#' \item `"embed"` - PDF only. Encode the same payload as JSON and pass it as the +#' `title` argument of the PDF graphics device, embedding it in the PDF `/Title` +#' metadata. The user-supplied `title` (via `...`) takes precedence and disables +#' the injection. +#' } #' @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,16 +1129,53 @@ 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 = TRUE, ...) { + standardGeneric("export_to") + } +) + +# Validate the `metadata` argument and return its canonical value. +# Accepts TRUE, FALSE, or the string "embed" (PDF only). +# @keywords internal +.check_metadata_arg <- function(metadata) { + if ( + !( + identical(metadata, TRUE) || + identical(metadata, FALSE) || + identical(metadata, "embed") + ) + ) { + stop( + "`metadata` must be TRUE, FALSE or the string \"embed\"." + ) + } + metadata +} + +# Write the JSON sidecar file for a metadata payload, if non-empty. +# @keywords internal +.write_metadata_sidecar <- function(payload, to) { + if (length(payload) == 0) { + return(invisible(NULL)) + } + json <- gridify_to_json(payload) + side <- paste0(to, ".json") + writeLines(json, con = side, useBytes = TRUE) + invisible(side) +} #' @rdname export_to #' @export -setMethod("export_to", "gridifyClass", function(x, to, device = NULL, ...) { +setMethod( + "export_to", + "gridifyClass", + function(x, to, device = NULL, metadata = TRUE, ...) { if (!(length(to) == 1 && is.character(to))) { stop("`to` must be a single string (file path) for single gridify object.") } + .check_metadata_arg(metadata) dir_name <- dirname(to) if (!(dir.exists(dir_name))) { @@ -1151,19 +1201,31 @@ setMethod("export_to", "gridifyClass", function(x, to, device = NULL, ...) { } user_args <- list(...) + payload <- if (isFALSE(metadata)) NULL else gridify_metadata(x) if (ext %in% c("pdf")) { default_args <- list(width = 11.69, height = 8.27) dev_args <- utils::modifyList(default_args, user_args) dev_args$file <- to + if ( + identical(metadata, "embed") && + length(payload) > 0 && + is.null(dev_args$title) + ) { + dev_args$title <- gridify_to_json(payload) + } + if (is.null(device)) { device <- grDevices::pdf } do.call(device, dev_args) + on.exit(grDevices::dev.off(), add = TRUE) print(x) - on.exit(grDevices::dev.off()) + if (isTRUE(metadata)) { + .write_metadata_sidecar(payload, to) + } } 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 +1244,28 @@ 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()) + if (isTRUE(metadata)) { + .write_metadata_sidecar(payload, to) + } } }) #' @rdname export_to #' @export -setMethod("export_to", "list", function(x, to, device = NULL, ...) { +setMethod( + "export_to", + "list", + function(x, to, device = NULL, metadata = TRUE, ...) { if ( !all(vapply(x, function(elem) inherits(elem, "gridifyClass"), logical(1))) ) { stop("All elements of the list must be 'gridifyClass' objects.") } + .check_metadata_arg(metadata) to_dirs <- dirname(to) dir_exists <- dir.exists(to_dirs) @@ -1231,18 +1300,36 @@ 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 (isFALSE(metadata)) { + NULL + } else { + lapply(x, gridify_metadata) + } + + user_args <- list(...) + dev_args <- utils::modifyList( + list(file = to, width = 11.69, height = 8.27, onefile = TRUE), + user_args ) + if ( + identical(metadata, "embed") && + length(payload) > 0 && + any(lengths(payload) > 0) && + is.null(dev_args$title) + ) { + dev_args$title <- gridify_to_json(payload) + } + + do.call(device, dev_args) on.exit(grDevices::dev.off(), add = TRUE) for (obj in x) { print(obj) } + + if (isTRUE(metadata)) { + .write_metadata_sidecar(payload, to) + } } else { stop( "For a list of gridify objects and a single file path, the `to` extension has to be pdf." @@ -1251,7 +1338,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 +1350,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 = TRUE, ...) { stop( "export_to is supported for gridifyClass or list of gridifyClass objects." ) diff --git a/man/export_to.Rd b/man/export_to.Rd index b78d105..0ad0a59 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 = TRUE, ...) -\S4method{export_to}{gridifyClass}(x, to, device = NULL, ...) +\S4method{export_to}{gridifyClass}(x, to, device = NULL, metadata = TRUE, ...) -\S4method{export_to}{list}(x, to, device = NULL, ...) +\S4method{export_to}{list}(x, to, device = NULL, metadata = TRUE, ...) -\S4method{export_to}{ANY}(x, to, device = NULL, ...) +\S4method{export_to}{ANY}(x, to, device = NULL, metadata = TRUE, ...) } \arguments{ \item{x}{A \code{gridifyClass} object or a list of \code{gridifyClass} objects.} @@ -24,6 +24,20 @@ 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 \code{set_cell()} text values. +One of: +\itemize{ +\item \code{TRUE} (default) - write a JSON sidecar file next to the output named \verb{.json} +containing a named list mapping cell name to its text value (or, for a multi-page PDF +built from a list of objects, a JSON array of such named lists, one per page). +No file is written when no cells were set. +\item \code{FALSE} - do not produce any metadata. +\item \code{"embed"} - PDF only. Encode the same payload as JSON and pass it as the +\code{title} argument of the PDF graphics device, embedding it in the PDF \verb{/Title} +metadata. The user-supplied \code{title} (via \code{...}) takes precedence and disables +the injection. +}} + \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..8f26346 --- /dev/null +++ b/man/gridify_metadata.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/grid_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 \code{text} field from each \code{set_cell()} element, in the order they +were added. Cells with \code{NULL} 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..f32b72b --- /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/grid_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/tests/testthat/test_export_to.R b/tests/testthat/test_export_to.R index 1effbe8..ec22a1f 100644 --- a/tests/testthat/test_export_to.R +++ b/tests/testthat/test_export_to.R @@ -191,3 +191,115 @@ 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("metadata = TRUE writes JSON sidecar for PDF and PNG", { + 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)) + expect_true(file.exists(out_file)) + expect_true(file.exists(side)) + + parsed <- jsonlite::fromJSON(side) + expect_identical(parsed$header_left_1, "My Company") + expect_identical(parsed$title_1, "<Title 1>") + expect_identical(parsed$watermark, "DRAFT \"x\" \\ y\nz") + } +}) + +test_that("metadata = FALSE 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 = FALSE)) + 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)) + expect_true(file.exists(out_file)) + expect_false(file.exists(side)) +}) + +test_that("metadata = 'embed' injects PDF /Title and skips sidecar", { + x <- mock_gridify_with_cells() + out_file <- file.path(tempdir(), "meta_embed.pdf") + side <- paste0(out_file, ".json") + if (file.exists(side)) file.remove(side) + + expect_no_error(export_to(x, out_file, metadata = "embed")) + expect_true(file.exists(out_file)) + expect_false(file.exists(side)) + + raw_bytes <- readBin(out_file, what = "raw", n = file.size(out_file)) + pdf_bytes <- rawToChar(raw_bytes[raw_bytes != as.raw(0)]) + expect_true(grepl("header_left_1", pdf_bytes, fixed = TRUE, useBytes = TRUE)) +}) + +test_that("metadata = 'embed' respects user-supplied title", { + x <- mock_gridify_with_cells() + out_file <- file.path(tempdir(), "meta_embed_user_title.pdf") + + expect_no_error(export_to( + x, + out_file, + metadata = "embed", + title = "MY TITLE" + )) + raw_bytes <- readBin(out_file, what = "raw", n = file.size(out_file)) + pdf_bytes <- rawToChar(raw_bytes[raw_bytes != as.raw(0)]) + expect_true(grepl("MY TITLE", pdf_bytes, fixed = TRUE, useBytes = TRUE)) + expect_false(grepl("header_left_1", pdf_bytes, fixed = TRUE, useBytes = TRUE)) +}) + +test_that("metadata sidecar for multi-page PDF is a JSON array", { + 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)) + expect_true(file.exists(side)) + + parsed <- jsonlite::fromJSON(side, simplifyVector = FALSE) + expect_length(parsed, 2) + expect_identical(parsed[[1]]$header_left_1, "My Company") + expect_identical(parsed[[2]]$header_left_1, "My Company") +}) + +test_that("metadata invalid values are rejected", { + x <- mock_gridify() + expect_error( + export_to(x, file.path(tempdir(), "bad.pdf"), metadata = "yes"), + "`metadata` must be TRUE, FALSE or the string \"embed\"\\." + ) + expect_error( + export_to( + list(x, x), + file.path(tempdir(), "bad.pdf"), + metadata = 1 + ), + "`metadata` must be TRUE, FALSE or the string \"embed\"\\." + ) +}) diff --git a/tests/testthat/test_gridify_to_json.R b/tests/testthat/test_gridify_to_json.R new file mode 100644 index 0000000..da7fdda --- /dev/null +++ b/tests/testthat/test_gridify_to_json.R @@ -0,0 +1,35 @@ +test_that("gridify_to_json round-trips basic payloads", { + 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", { + s <- "DRAFT \"x\" \\ y\nz" + json <- gridify_to_json(list(w = s)) + expect_identical(jsonlite::fromJSON(json)$w, s) +}) + +test_that("gridify_metadata extracts only set_cell text values", { + obj <- gridify(grid::rectGrob(), pharma_layout_base()) + obj <- set_cell(obj, "header_left_1", "Co") + obj <- set_cell(obj, "title_1", "T1") + meta <- gridify_metadata(obj) + expect_identical(meta, list(header_left_1 = "Co", title_1 = "T1")) +}) + +test_that("gridify_metadata returns empty list for no cells", { + obj <- gridify(grid::rectGrob(), simple_layout()) + expect_identical(gridify_metadata(obj), stats::setNames(list(), character(0))) +}) diff --git a/vignettes/multi_page_examples.Rmd b/vignettes/multi_page_examples.Rmd index 8562cf2..f504aa5 100644 --- a/vignettes/multi_page_examples.Rmd +++ b/vignettes/multi_page_examples.Rmd @@ -347,6 +347,11 @@ export_to( ) ``` +For multi-page PDFs the JSON sidecar (`<file>.pdf.json`, written by default) +contains a JSON array with one object per page, listing the `set_cell()` text +values for each page. Pass `metadata = FALSE` to disable it, or +`metadata = "embed"` to encode the array in the PDF `/Title` instead. + ### 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..29a1cbc 100644 --- a/vignettes/simple_examples.Rmd +++ b/vignettes/simple_examples.Rmd @@ -765,6 +765,25 @@ 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 + +By default `export_to()` also writes a JSON sidecar file next to the output +(e.g. `output.pdf.json`) containing the text values you set with `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. + +```{r, eval = FALSE} +# Default: writes both output.pdf and output.pdf.json +export_to(gridify_obj, to = "output.pdf") + +# Disable the sidecar +export_to(gridify_obj, to = "output.pdf", metadata = FALSE) + +# PDF only: embed the same JSON payload in the PDF /Title metadata +# (no sidecar file is written in this mode) +export_to(gridify_obj, to = "output.pdf", metadata = "embed") +``` + ## Conclusion These examples should give you a good understanding of how to use the `gridify` package to add text From d941ad9c88d2786dab0ac2142624996c8f109da6 Mon Sep 17 00:00:00 2001 From: Maciej Nasinski <Nasinski.maciej@gmail.com> Date: Mon, 11 May 2026 10:19:55 +0200 Subject: [PATCH 02/10] improve arg --- DESCRIPTION | 4 +- NEWS.md | 9 ++-- R/gridify-methods.R | 70 ++++++++------------------- R/{grid_utils.R => gridify-utils.R} | 26 ++++++++++ man/export_to.Rd | 17 ++++--- man/gpar_args.Rd | 2 +- man/gpar_call.Rd | 2 +- man/grid_unit_type.Rd | 2 +- man/gridify_metadata.Rd | 2 +- man/gridify_to_json.Rd | 2 +- man/write_metadata_sidecar.Rd | 28 +++++++++++ tests/testthat/test_export_to.R | 21 +++++--- tests/testthat/test_gridify_to_json.R | 37 ++++++++++++++ vignettes/multi_page_examples.Rmd | 2 +- vignettes/simple_examples.Rmd | 2 +- 15 files changed, 149 insertions(+), 77 deletions(-) rename R/{grid_utils.R => gridify-utils.R} (70%) create mode 100644 man/write_metadata_sidecar.Rd diff --git a/DESCRIPTION b/DESCRIPTION index f02ef6b..735c6d2 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -24,7 +24,6 @@ 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, @@ -42,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 @@ -55,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 5a555f6..1ea1bb4 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,11 +1,10 @@ # gridify 0.7.7.9000 * `export_to()` gains a `metadata` argument that records `set_cell()` text values - alongside the exported output. With the default `metadata = TRUE` a JSON - sidecar `<file>.json` is written next to the output (no extra dependencies); - `metadata = "embed"` (PDF only) injects the same JSON payload as the PDF - `/Title` so the metadata travels inside the file. Set `metadata = FALSE` to - preserve the previous behaviour. + alongside the exported output. With the default `metadata = "sidecar"` a JSON + sidecar `<file>.json` is written next to the output; `metadata = "embed"` + (PDF only) injects the same JSON payload as the PDF `/Title` so the metadata + travels inside the file. Set `metadata = "none"` to disable the feature. # gridify 0.7.7 diff --git a/R/gridify-methods.R b/R/gridify-methods.R index 5cdbd8a..4d2bdec 100644 --- a/R/gridify-methods.R +++ b/R/gridify-methods.R @@ -944,16 +944,17 @@ setMethod("show", "gridifyLayout", function(object) { #' @param metadata Controls writing of metadata derived from `set_cell()` text values. #' One of: #' \itemize{ -#' \item `TRUE` (default) - write a JSON sidecar file next to the output named `<to>.json` +#' \item `"sidecar"` (default) - write a JSON sidecar file next to the output named `<to>.json` #' containing a named list mapping cell name to its text value (or, for a multi-page PDF #' built from a list of objects, a JSON array of such named lists, one per page). #' No file is written when no cells were set. -#' \item `FALSE` - do not produce any metadata. #' \item `"embed"` - PDF only. Encode the same payload as JSON and pass it as the #' `title` argument of the PDF graphics device, embedding it in the PDF `/Title` #' metadata. The user-supplied `title` (via `...`) takes precedence and disables -#' the injection. +#' the injection. No sidecar file is written. +#' \item `"none"` - do not produce any metadata. #' } +#' Validated with [match.arg()] so it can be abbreviated. #' @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: @@ -1131,51 +1132,22 @@ setMethod("show", "gridifyLayout", function(object) { #' @export setGeneric( "export_to", - function(x, to, device = NULL, metadata = TRUE, ...) { + function(x, to, device = NULL, metadata = c("sidecar", "embed", "none"), ...) { standardGeneric("export_to") } ) -# Validate the `metadata` argument and return its canonical value. -# Accepts TRUE, FALSE, or the string "embed" (PDF only). -# @keywords internal -.check_metadata_arg <- function(metadata) { - if ( - !( - identical(metadata, TRUE) || - identical(metadata, FALSE) || - identical(metadata, "embed") - ) - ) { - stop( - "`metadata` must be TRUE, FALSE or the string \"embed\"." - ) - } - metadata -} - -# Write the JSON sidecar file for a metadata payload, if non-empty. -# @keywords internal -.write_metadata_sidecar <- function(payload, to) { - if (length(payload) == 0) { - return(invisible(NULL)) - } - json <- gridify_to_json(payload) - side <- paste0(to, ".json") - writeLines(json, con = side, useBytes = TRUE) - invisible(side) -} - #' @rdname export_to #' @export setMethod( "export_to", "gridifyClass", - function(x, to, device = NULL, metadata = TRUE, ...) { + function(x, to, device = NULL, metadata = c("sidecar", "embed", "none"), ...) { if (!(length(to) == 1 && is.character(to))) { stop("`to` must be a single string (file path) for single gridify object.") } - .check_metadata_arg(metadata) + + metadata <- match.arg(metadata) dir_name <- dirname(to) if (!(dir.exists(dir_name))) { @@ -1201,7 +1173,7 @@ setMethod( } user_args <- list(...) - payload <- if (isFALSE(metadata)) NULL else gridify_metadata(x) + payload <- if (metadata == "none") NULL else gridify_metadata(x) if (ext %in% c("pdf")) { default_args <- list(width = 11.69, height = 8.27) @@ -1209,7 +1181,7 @@ setMethod( dev_args$file <- to if ( - identical(metadata, "embed") && + metadata == "embed" && length(payload) > 0 && is.null(dev_args$title) ) { @@ -1223,8 +1195,8 @@ setMethod( do.call(device, dev_args) on.exit(grDevices::dev.off(), add = TRUE) print(x) - if (isTRUE(metadata)) { - .write_metadata_sidecar(payload, to) + if (metadata == "sidecar") { + write_metadata_sidecar(payload, to) } } else if (ext %in% c("png", "jpeg", "jpg", "tiff", "tif")) { default_args <- list(width = 600, height = 400) @@ -1247,8 +1219,8 @@ setMethod( on.exit(grDevices::dev.off(), add = TRUE) grid::grid.newpage() print(x) - if (isTRUE(metadata)) { - .write_metadata_sidecar(payload, to) + if (metadata == "sidecar") { + write_metadata_sidecar(payload, to) } } }) @@ -1259,13 +1231,13 @@ setMethod( setMethod( "export_to", "list", - function(x, to, device = NULL, metadata = TRUE, ...) { + function(x, to, device = NULL, metadata = c("sidecar", "embed", "none"), ...) { if ( !all(vapply(x, function(elem) inherits(elem, "gridifyClass"), logical(1))) ) { stop("All elements of the list must be 'gridifyClass' objects.") } - .check_metadata_arg(metadata) + metadata <- match.arg(metadata) to_dirs <- dirname(to) dir_exists <- dir.exists(to_dirs) @@ -1300,7 +1272,7 @@ setMethod( device <- grDevices::pdf } - payload <- if (isFALSE(metadata)) { + payload <- if (metadata == "none") { NULL } else { lapply(x, gridify_metadata) @@ -1312,7 +1284,7 @@ setMethod( user_args ) if ( - identical(metadata, "embed") && + metadata == "embed" && length(payload) > 0 && any(lengths(payload) > 0) && is.null(dev_args$title) @@ -1327,8 +1299,8 @@ setMethod( print(obj) } - if (isTRUE(metadata)) { - .write_metadata_sidecar(payload, to) + if (metadata == "sidecar") { + write_metadata_sidecar(payload, to) } } else { stop( @@ -1350,7 +1322,7 @@ setMethod( #' @rdname export_to #' @export -setMethod("export_to", "ANY", function(x, to, device = NULL, metadata = TRUE, ...) { +setMethod("export_to", "ANY", function(x, to, device = NULL, metadata = c("sidecar", "embed", "none"), ...) { stop( "export_to is supported for gridifyClass or list of gridifyClass objects." ) diff --git a/R/grid_utils.R b/R/gridify-utils.R similarity index 70% rename from R/grid_utils.R rename to R/gridify-utils.R index 3ec474d..425cdca 100644 --- a/R/grid_utils.R +++ b/R/gridify-utils.R @@ -81,3 +81,29 @@ gridify_to_json <- function(x) { na = "null" )) } + +#' Write the JSON metadata sidecar file +#' +#' Encodes the metadata `payload` as JSON via [gridify_to_json()] and writes it +#' to `paste0(to, ".json")`. The file is written with `useBytes = TRUE` so the +#' UTF-8 bytes produced by `jsonlite::toJSON()` are preserved verbatim. +#' If `payload` is `NULL` or empty no file is created and `NULL` is returned +#' (this keeps `export_to()` from producing empty sidecars when no cells were +#' set). +#' +#' @param payload A named list (single page) or list of named lists +#' (multi-page) of metadata values to serialise. +#' @param to A length-one character string with the path of the main output +#' file. The sidecar path is `paste0(to, ".json")`. +#' @return Invisibly, the path of the sidecar file that was written, or `NULL` +#' when no file was written. +#' @keywords internal +write_metadata_sidecar <- function(payload, to) { + if (length(payload) == 0) { + return(invisible(NULL)) + } + json <- gridify_to_json(payload) + side <- paste0(to, ".json") + writeLines(json, con = side, useBytes = TRUE) + invisible(side) +} \ No newline at end of file diff --git a/man/export_to.Rd b/man/export_to.Rd index 0ad0a59..156f246 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, metadata = TRUE, ...) +export_to(x, to, device = NULL, metadata = c("sidecar", "embed", "none"), ...) -\S4method{export_to}{gridifyClass}(x, to, device = NULL, metadata = TRUE, ...) +\S4method{export_to}{gridifyClass}(x, to, device = NULL, metadata = c("sidecar", "embed", "none"), ...) -\S4method{export_to}{list}(x, to, device = NULL, metadata = TRUE, ...) +\S4method{export_to}{list}(x, to, device = NULL, metadata = c("sidecar", "embed", "none"), ...) -\S4method{export_to}{ANY}(x, to, device = NULL, metadata = TRUE, ...) +\S4method{export_to}{ANY}(x, to, device = NULL, metadata = c("sidecar", "embed", "none"), ...) } \arguments{ \item{x}{A \code{gridifyClass} object or a list of \code{gridifyClass} objects.} @@ -27,16 +27,17 @@ By default a file name extension is used to choose a graphics device function. D \item{metadata}{Controls writing of metadata derived from \code{set_cell()} text values. One of: \itemize{ -\item \code{TRUE} (default) - write a JSON sidecar file next to the output named \verb{<to>.json} +\item \code{"sidecar"} (default) - write a JSON sidecar file next to the output named \verb{<to>.json} containing a named list mapping cell name to its text value (or, for a multi-page PDF built from a list of objects, a JSON array of such named lists, one per page). No file is written when no cells were set. -\item \code{FALSE} - do not produce any metadata. \item \code{"embed"} - PDF only. Encode the same payload as JSON and pass it as the \code{title} argument of the PDF graphics device, embedding it in the PDF \verb{/Title} metadata. The user-supplied \code{title} (via \code{...}) takes precedence and disables -the injection. -}} +the injection. No sidecar file is written. +\item \code{"none"} - do not produce any metadata. +} +Validated with \code{\link[=match.arg]{match.arg()}} so it can be abbreviated.} \item{...}{Additional arguments passed to the graphics device functions (\code{pdf()}, \code{png()}, \code{tiff()}, \code{jpeg()} or your custom one). 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 index 8f26346..4e7205f 100644 --- a/man/gridify_metadata.Rd +++ b/man/gridify_metadata.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{gridify_metadata} \alias{gridify_metadata} \title{Build the metadata payload for a \code{gridifyClass} object.} diff --git a/man/gridify_to_json.Rd b/man/gridify_to_json.Rd index f32b72b..608d69a 100644 --- a/man/gridify_to_json.Rd +++ b/man/gridify_to_json.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{gridify_to_json} \alias{gridify_to_json} \title{Encode a metadata payload as JSON via \code{jsonlite}.} diff --git a/man/write_metadata_sidecar.Rd b/man/write_metadata_sidecar.Rd new file mode 100644 index 0000000..3c581ce --- /dev/null +++ b/man/write_metadata_sidecar.Rd @@ -0,0 +1,28 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/gridify-utils.R +\name{write_metadata_sidecar} +\alias{write_metadata_sidecar} +\title{Write the JSON metadata sidecar file} +\usage{ +write_metadata_sidecar(payload, to) +} +\arguments{ +\item{payload}{A named list (single page) or list of named lists +(multi-page) of metadata values to serialise.} + +\item{to}{A length-one character string with the path of the main output +file. The sidecar path is \code{paste0(to, ".json")}.} +} +\value{ +Invisibly, the path of the sidecar file that was written, or \code{NULL} +when no file was written. +} +\description{ +Encodes the metadata \code{payload} as JSON via \code{\link[=gridify_to_json]{gridify_to_json()}} and writes it +to \code{paste0(to, ".json")}. The file is written with \code{useBytes = TRUE} so the +UTF-8 bytes produced by \code{jsonlite::toJSON()} are preserved verbatim. +If \code{payload} is \code{NULL} or empty no file is created and \code{NULL} is returned +(this keeps \code{export_to()} from producing empty sidecars when no cells were +set). +} +\keyword{internal} diff --git a/tests/testthat/test_export_to.R b/tests/testthat/test_export_to.R index ec22a1f..e67fb99 100644 --- a/tests/testthat/test_export_to.R +++ b/tests/testthat/test_export_to.R @@ -201,7 +201,7 @@ mock_gridify_with_cells <- function() { obj } -test_that("metadata = TRUE writes JSON sidecar for PDF and PNG", { +test_that("metadata = 'sidecar' (default) writes JSON sidecar for PDF and PNG", { x <- mock_gridify_with_cells() for (ext in c("pdf", "png")) { @@ -220,13 +220,13 @@ test_that("metadata = TRUE writes JSON sidecar for PDF and PNG", { } }) -test_that("metadata = FALSE writes no sidecar", { +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 = FALSE)) + expect_no_error(export_to(x, out_file, metadata = "none")) expect_true(file.exists(out_file)) expect_false(file.exists(side)) }) @@ -288,18 +288,27 @@ test_that("metadata sidecar for multi-page PDF is a JSON array", { expect_identical(parsed[[2]]$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"), - "`metadata` must be TRUE, FALSE or the string \"embed\"\\." + "should be one of" ) expect_error( export_to( list(x, x), file.path(tempdir(), "bad.pdf"), metadata = 1 - ), - "`metadata` must be TRUE, FALSE or the string \"embed\"\\." + ) ) }) diff --git a/tests/testthat/test_gridify_to_json.R b/tests/testthat/test_gridify_to_json.R index da7fdda..09cb33a 100644 --- a/tests/testthat/test_gridify_to_json.R +++ b/tests/testthat/test_gridify_to_json.R @@ -33,3 +33,40 @@ test_that("gridify_metadata returns empty list for no cells", { obj <- gridify(grid::rectGrob(), simple_layout()) expect_identical(gridify_metadata(obj), stats::setNames(list(), character(0))) }) + +test_that("write_metadata_sidecar writes JSON file and returns its path", { + base <- tempfile(fileext = ".pdf") + side <- paste0(base, ".json") + if (file.exists(side)) file.remove(side) + + payload <- list(a = "x", b = "y") + res <- write_metadata_sidecar(payload, base) + + expect_identical(res, side) + expect_true(file.exists(side)) + expect_identical(jsonlite::fromJSON(side), payload) +}) + +test_that("write_metadata_sidecar skips empty payloads", { + base <- tempfile(fileext = ".pdf") + side <- paste0(base, ".json") + if (file.exists(side)) file.remove(side) + + expect_null(write_metadata_sidecar(list(), base)) + expect_null(write_metadata_sidecar(NULL, base)) + expect_false(file.exists(side)) +}) + +test_that("write_metadata_sidecar serialises multi-page list payload", { + base <- tempfile(fileext = ".pdf") + side <- paste0(base, ".json") + if (file.exists(side)) file.remove(side) + + payload <- list(list(a = "1"), list(a = "2")) + write_metadata_sidecar(payload, base) + + parsed <- jsonlite::fromJSON(side, simplifyVector = FALSE) + expect_length(parsed, 2) + expect_identical(parsed[[1]]$a, "1") + expect_identical(parsed[[2]]$a, "2") +}) diff --git a/vignettes/multi_page_examples.Rmd b/vignettes/multi_page_examples.Rmd index f504aa5..8d2fe27 100644 --- a/vignettes/multi_page_examples.Rmd +++ b/vignettes/multi_page_examples.Rmd @@ -349,7 +349,7 @@ export_to( For multi-page PDFs the JSON sidecar (`<file>.pdf.json`, written by default) contains a JSON array with one object per page, listing the `set_cell()` text -values for each page. Pass `metadata = FALSE` to disable it, or +values for each page. Pass `metadata = "none"` to disable it, or `metadata = "embed"` to encode the array in the PDF `/Title` instead. ### Extending The Multi-Page Example diff --git a/vignettes/simple_examples.Rmd b/vignettes/simple_examples.Rmd index 29a1cbc..ebb3890 100644 --- a/vignettes/simple_examples.Rmd +++ b/vignettes/simple_examples.Rmd @@ -777,7 +777,7 @@ produce a given figure without parsing the file. export_to(gridify_obj, to = "output.pdf") # Disable the sidecar -export_to(gridify_obj, to = "output.pdf", metadata = FALSE) +export_to(gridify_obj, to = "output.pdf", metadata = "none") # PDF only: embed the same JSON payload in the PDF /Title metadata # (no sidecar file is written in this mode) From 42b879544d7fc65bd909f5f06d71f7506d80d22a Mon Sep 17 00:00:00 2001 From: Maciej Nasinski <Nasinski.maciej@gmail.com> Date: Mon, 11 May 2026 10:52:12 +0200 Subject: [PATCH 03/10] none as default and global option --- NEWS.md | 9 +- R/grid_utils.R | 131 +++++++++++++++++++++++++++++ R/gridify-methods.R | 21 +++-- man/export_to.Rd | 19 +++-- man/gpar_args.Rd | 8 +- man/gpar_call.Rd | 8 +- man/grid_unit_type.Rd | 8 +- man/is_flexible_grob.Rd | 32 +++++++ man/object_viewport_height_expr.Rd | 35 ++++++++ man/resolve_export_metadata.Rd | 27 ++++++ tests/testthat/test_export_to.R | 60 ++++++++++++- vignettes/multi_page_examples.Rmd | 10 ++- vignettes/simple_examples.Rmd | 21 +++-- 13 files changed, 352 insertions(+), 37 deletions(-) create mode 100644 R/grid_utils.R create mode 100644 man/is_flexible_grob.Rd create mode 100644 man/object_viewport_height_expr.Rd create mode 100644 man/resolve_export_metadata.Rd diff --git a/NEWS.md b/NEWS.md index 1ea1bb4..f817dca 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,10 +1,11 @@ # gridify 0.7.7.9000 * `export_to()` gains a `metadata` argument that records `set_cell()` text values - alongside the exported output. With the default `metadata = "sidecar"` a JSON - sidecar `<file>.json` is written next to the output; `metadata = "embed"` - (PDF only) injects the same JSON payload as the PDF `/Title` so the metadata - travels inside the file. Set `metadata = "none"` to disable the feature. + alongside the exported output. The default is `metadata = "none"`; pass + `"sidecar"` to write a JSON sidecar `<file>.json` next to the output, or + `"embed"` (PDF only) to inject the same JSON payload as the PDF `/Title` + so the metadata travels inside the file. The default can be changed + project-wide by setting `options(gridify.export.metadata = "sidecar")`. # gridify 0.7.7 diff --git a/R/grid_utils.R b/R/grid_utils.R new file mode 100644 index 0000000..fdc4bd2 --- /dev/null +++ b/R/grid_utils.R @@ -0,0 +1,131 @@ +#' 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))) +} + +#' Detect a "flexible" grob whose natural height is not meaningful +#' +#' A flexible grob is one designed to fill whatever container it is placed +#' in, so `grid::grobHeight()` cannot be used to size its viewport. Two +#' shapes are recognised: +#' * gtables with at least one `null` unit in their `heights` — +#' e.g. `ggplot2::ggplotGrob()`. +#' * recorded gTrees produced by `grid::grid.grabExpr()` (used internally +#' for the `formula` input type via `gridGraphics::grid.echo()`); these +#' carry a `childrenvp` describing the recording viewport, outside of +#' which `grobHeight()` collapses to 0. +#' +#' All other grobs (`gt::as_gtable()`, `flextable::gen_grob()`, plain +#' `grid::rectGrob()` / `grid::nullGrob()`, ...) are treated as fixed-size. +#' +#' @param grob a grob. +#' @return `TRUE` if `grob` is flexible, `FALSE` otherwise. +#' @keywords internal +is_flexible_grob <- function(grob) { + if (inherits(grob, "gtable")) { + return(any(grid::unitType(grob$heights) == "null")) + } + if (inherits(grob, "gTree")) { + return(!is.null(grob$childrenvp)) + } + FALSE +} + +#' Build the viewport-height expression for the object's grob +#' +#' Chooses between the grob's natural height (`grid::grobHeight()`) and a +#' layout-driven height in npc, then floors the result at 1 inch via +#' `grid::unit.pmax()` so the viewport never collapses. +#' +#' The natural height is used only when the caller has opted into vertical +#' anchoring (`vjust != 0.5`) and the grob is not flexible +#' (see `is_flexible_grob()`). The `vjust == 0.5` case is preserved for +#' byte-for-byte backwards compatibility with the historical +#' "fill the row" behaviour. +#' +#' The returned expression references an unbound symbol `OBJECT`; the +#' caller is responsible for evaluating it in an environment that binds +#' `OBJECT` to the grob. +#' +#' @param grob a grob; used only for flexibility detection. +#' @param vjust numeric, the layout's object vjust. +#' @param height numeric, the layout's object height (in npc). +#' @return an unevaluated call producing a `grid::unit`. +#' @keywords internal +object_viewport_height_expr <- function(grob, vjust, height) { + natural_height <- if (vjust != 0.5 && !is_flexible_grob(grob)) { + quote(grid::grobHeight(OBJECT)) + } else { + substitute(grid::unit(h, "npc"), list(h = height)) + } + substitute( + grid::unit.pmax(NH, grid::unit(1, "inch")), + list(NH = natural_height) + ) +} + +#' 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"`, `"embed"`. +#' @keywords internal +resolve_export_metadata <- function(metadata) { + choices <- c("none", "sidecar", "embed") + if (is.null(metadata)) { + metadata <- getOption("gridify.export.metadata", "none") + } + match.arg(metadata, choices) +} diff --git a/R/gridify-methods.R b/R/gridify-methods.R index 4d2bdec..a928b74 100644 --- a/R/gridify-methods.R +++ b/R/gridify-methods.R @@ -944,7 +944,7 @@ setMethod("show", "gridifyLayout", function(object) { #' @param metadata Controls writing of metadata derived from `set_cell()` text values. #' One of: #' \itemize{ -#' \item `"sidecar"` (default) - write a JSON sidecar file next to the output named `<to>.json` +#' \item `"sidecar"` - write a JSON sidecar file next to the output named `<to>.json` #' containing a named list mapping cell name to its text value (or, for a multi-page PDF #' built from a list of objects, a JSON array of such named lists, one per page). #' No file is written when no cells were set. @@ -952,9 +952,14 @@ setMethod("show", "gridifyLayout", function(object) { #' `title` argument of the PDF graphics device, embedding it in the PDF `/Title` #' metadata. The user-supplied `title` (via `...`) takes precedence and disables #' the injection. No sidecar file is written. -#' \item `"none"` - do not produce any metadata. +#' \item `"none"` (default) - do not produce any metadata. #' } #' 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: @@ -1132,7 +1137,7 @@ setMethod("show", "gridifyLayout", function(object) { #' @export setGeneric( "export_to", - function(x, to, device = NULL, metadata = c("sidecar", "embed", "none"), ...) { + function(x, to, device = NULL, metadata = NULL, ...) { standardGeneric("export_to") } ) @@ -1142,12 +1147,12 @@ setGeneric( setMethod( "export_to", "gridifyClass", - function(x, to, device = NULL, metadata = c("sidecar", "embed", "none"), ...) { + 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 <- match.arg(metadata) + metadata <- resolve_export_metadata(metadata) dir_name <- dirname(to) if (!(dir.exists(dir_name))) { @@ -1231,13 +1236,13 @@ setMethod( setMethod( "export_to", "list", - function(x, to, device = NULL, metadata = c("sidecar", "embed", "none"), ...) { + 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 <- match.arg(metadata) + metadata <- resolve_export_metadata(metadata) to_dirs <- dirname(to) dir_exists <- dir.exists(to_dirs) @@ -1322,7 +1327,7 @@ setMethod( #' @rdname export_to #' @export -setMethod("export_to", "ANY", function(x, to, device = NULL, metadata = c("sidecar", "embed", "none"), ...) { +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/man/export_to.Rd b/man/export_to.Rd index 156f246..d64ac70 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, metadata = c("sidecar", "embed", "none"), ...) +export_to(x, to, device = NULL, metadata = NULL, ...) -\S4method{export_to}{gridifyClass}(x, to, device = NULL, metadata = c("sidecar", "embed", "none"), ...) +\S4method{export_to}{gridifyClass}(x, to, device = NULL, metadata = NULL, ...) -\S4method{export_to}{list}(x, to, device = NULL, metadata = c("sidecar", "embed", "none"), ...) +\S4method{export_to}{list}(x, to, device = NULL, metadata = NULL, ...) -\S4method{export_to}{ANY}(x, to, device = NULL, metadata = c("sidecar", "embed", "none"), ...) +\S4method{export_to}{ANY}(x, to, device = NULL, metadata = NULL, ...) } \arguments{ \item{x}{A \code{gridifyClass} object or a list of \code{gridifyClass} objects.} @@ -27,7 +27,7 @@ By default a file name extension is used to choose a graphics device function. D \item{metadata}{Controls writing of metadata derived from \code{set_cell()} text values. One of: \itemize{ -\item \code{"sidecar"} (default) - write a JSON sidecar file next to the output named \verb{<to>.json} +\item \code{"sidecar"} - write a JSON sidecar file next to the output named \verb{<to>.json} containing a named list mapping cell name to its text value (or, for a multi-page PDF built from a list of objects, a JSON array of such named lists, one per page). No file is written when no cells were set. @@ -35,9 +35,14 @@ No file is written when no cells were set. \code{title} argument of the PDF graphics device, embedding it in the PDF \verb{/Title} metadata. The user-supplied \code{title} (via \code{...}) takes precedence and disables the injection. No sidecar file is written. -\item \code{"none"} - do not produce any metadata. +\item \code{"none"} (default) - do not produce any metadata. } -Validated with \code{\link[=match.arg]{match.arg()}} so it can be abbreviated.} +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). diff --git a/man/gpar_args.Rd b/man/gpar_args.Rd index 4dd8b1a..4caf59c 100644 --- a/man/gpar_args.Rd +++ b/man/gpar_args.Rd @@ -1,18 +1,24 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/gridify-utils.R +% Please edit documentation in R/gridify-utils.R, R/grid_utils.R \name{gpar_args} \alias{gpar_args} \title{Get \code{grid::gpar} arguments} \usage{ +gpar_args(gpar) + gpar_args(gpar) } \arguments{ \item{gpar}{a \code{grid::gpar} object.} } \value{ +a list. + a list. } \description{ +Get \code{grid::gpar} arguments + Get \code{grid::gpar} arguments } \keyword{internal} diff --git a/man/gpar_call.Rd b/man/gpar_call.Rd index e45d20b..58e4dcc 100644 --- a/man/gpar_call.Rd +++ b/man/gpar_call.Rd @@ -1,18 +1,24 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/gridify-utils.R +% Please edit documentation in R/gridify-utils.R, R/grid_utils.R \name{gpar_call} \alias{gpar_call} \title{Convert \code{grid::gpar} to a call} \usage{ +gpar_call(gpar) + gpar_call(gpar) } \arguments{ \item{gpar}{a \code{grid::gpar} object.} } \value{ +a call. + a call. } \description{ +Convert \code{grid::gpar} to a call + Convert \code{grid::gpar} to a call } \keyword{internal} diff --git a/man/grid_unit_type.Rd b/man/grid_unit_type.Rd index 0c7a070..2bf01ab 100644 --- a/man/grid_unit_type.Rd +++ b/man/grid_unit_type.Rd @@ -1,9 +1,11 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/gridify-utils.R +% Please edit documentation in R/gridify-utils.R, R/grid_utils.R \name{grid_unit_type} \alias{grid_unit_type} \title{Wrapper for \code{grid::unitType} which supports older R versions} \usage{ +grid_unit_type(x, use_grid = TRUE) + grid_unit_type(x, use_grid = TRUE) } \arguments{ @@ -14,9 +16,13 @@ The main purpose of this argument is to have full test coverage in tests. Default TRUE.} } \value{ +a character vector with unit type for each element. + a character vector with unit type for each element. } \description{ +Wrapper for \code{grid::unitType} which supports older R versions + Wrapper for \code{grid::unitType} which supports older R versions } \keyword{internal} diff --git a/man/is_flexible_grob.Rd b/man/is_flexible_grob.Rd new file mode 100644 index 0000000..7f38045 --- /dev/null +++ b/man/is_flexible_grob.Rd @@ -0,0 +1,32 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/grid_utils.R +\name{is_flexible_grob} +\alias{is_flexible_grob} +\title{Detect a "flexible" grob whose natural height is not meaningful} +\usage{ +is_flexible_grob(grob) +} +\arguments{ +\item{grob}{a grob.} +} +\value{ +\code{TRUE} if \code{grob} is flexible, \code{FALSE} otherwise. +} +\description{ +A flexible grob is one designed to fill whatever container it is placed +in, so \code{grid::grobHeight()} cannot be used to size its viewport. Two +shapes are recognised: +\itemize{ +\item gtables with at least one \code{null} unit in their \code{heights} — +e.g. \code{ggplot2::ggplotGrob()}. +\item recorded gTrees produced by \code{grid::grid.grabExpr()} (used internally +for the \code{formula} input type via \code{gridGraphics::grid.echo()}); these +carry a \code{childrenvp} describing the recording viewport, outside of +which \code{grobHeight()} collapses to 0. +} +} +\details{ +All other grobs (\code{gt::as_gtable()}, \code{flextable::gen_grob()}, plain +\code{grid::rectGrob()} / \code{grid::nullGrob()}, ...) are treated as fixed-size. +} +\keyword{internal} diff --git a/man/object_viewport_height_expr.Rd b/man/object_viewport_height_expr.Rd new file mode 100644 index 0000000..7bfc83c --- /dev/null +++ b/man/object_viewport_height_expr.Rd @@ -0,0 +1,35 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/grid_utils.R +\name{object_viewport_height_expr} +\alias{object_viewport_height_expr} +\title{Build the viewport-height expression for the object's grob} +\usage{ +object_viewport_height_expr(grob, vjust, height) +} +\arguments{ +\item{grob}{a grob; used only for flexibility detection.} + +\item{vjust}{numeric, the layout's object vjust.} + +\item{height}{numeric, the layout's object height (in npc).} +} +\value{ +an unevaluated call producing a \code{grid::unit}. +} +\description{ +Chooses between the grob's natural height (\code{grid::grobHeight()}) and a +layout-driven height in npc, then floors the result at 1 inch via +\code{grid::unit.pmax()} so the viewport never collapses. +} +\details{ +The natural height is used only when the caller has opted into vertical +anchoring (\code{vjust != 0.5}) and the grob is not flexible +(see \code{is_flexible_grob()}). The \code{vjust == 0.5} case is preserved for +byte-for-byte backwards compatibility with the historical +"fill the row" behaviour. + +The returned expression references an unbound symbol \code{OBJECT}; the +caller is responsible for evaluating it in an environment that binds +\code{OBJECT} to the grob. +} +\keyword{internal} diff --git a/man/resolve_export_metadata.Rd b/man/resolve_export_metadata.Rd new file mode 100644 index 0000000..1ca97d8 --- /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/grid_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"}, \code{"embed"}. +} +\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/tests/testthat/test_export_to.R b/tests/testthat/test_export_to.R index e67fb99..515f836 100644 --- a/tests/testthat/test_export_to.R +++ b/tests/testthat/test_export_to.R @@ -201,7 +201,20 @@ mock_gridify_with_cells <- function() { obj } -test_that("metadata = 'sidecar' (default) writes JSON sidecar for PDF and PNG", { +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", { x <- mock_gridify_with_cells() for (ext in c("pdf", "png")) { @@ -209,7 +222,7 @@ test_that("metadata = 'sidecar' (default) writes JSON sidecar for PDF and PNG", side <- paste0(out_file, ".json") if (file.exists(side)) file.remove(side) - expect_no_error(export_to(x, out_file)) + expect_no_error(export_to(x, out_file, metadata = "sidecar")) expect_true(file.exists(out_file)) expect_true(file.exists(side)) @@ -220,6 +233,45 @@ test_that("metadata = 'sidecar' (default) writes JSON sidecar for PDF and PNG", } }) +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") @@ -237,7 +289,7 @@ test_that("metadata writes no sidecar when no cells are set", { side <- paste0(out_file, ".json") if (file.exists(side)) file.remove(side) - expect_no_error(export_to(x, out_file)) + expect_no_error(export_to(x, out_file, metadata = "sidecar")) expect_true(file.exists(out_file)) expect_false(file.exists(side)) }) @@ -279,7 +331,7 @@ test_that("metadata sidecar for multi-page PDF is a JSON array", { side <- paste0(out_file, ".json") if (file.exists(side)) file.remove(side) - expect_no_error(export_to(x_list, out_file)) + expect_no_error(export_to(x_list, out_file, metadata = "sidecar")) expect_true(file.exists(side)) parsed <- jsonlite::fromJSON(side, simplifyVector = FALSE) diff --git a/vignettes/multi_page_examples.Rmd b/vignettes/multi_page_examples.Rmd index 8d2fe27..b932346 100644 --- a/vignettes/multi_page_examples.Rmd +++ b/vignettes/multi_page_examples.Rmd @@ -347,10 +347,12 @@ export_to( ) ``` -For multi-page PDFs the JSON sidecar (`<file>.pdf.json`, written by default) -contains a JSON array with one object per page, listing the `set_cell()` text -values for each page. Pass `metadata = "none"` to disable it, or -`metadata = "embed"` to encode the array in the PDF `/Title` instead. +For multi-page PDFs, passing `metadata = "sidecar"` writes a JSON sidecar +(`<file>.pdf.json`) containing a JSON array with one object per page, listing +the `set_cell()` text values for each page. Pass `metadata = "embed"` to +encode the array in the PDF `/Title` instead. The default is `"none"` (no +metadata). Set `options(gridify.export.metadata = "sidecar")` to change the +default project-wide. ### Extending The Multi-Page Example diff --git a/vignettes/simple_examples.Rmd b/vignettes/simple_examples.Rmd index ebb3890..d0ad8e2 100644 --- a/vignettes/simple_examples.Rmd +++ b/vignettes/simple_examples.Rmd @@ -767,23 +767,30 @@ export_to(gridify_obj, to = "output.jpeg", width = 2400, height = 1800, res = 30 ### Metadata -By default `export_to()` also writes a JSON sidecar file next to the output -(e.g. `output.pdf.json`) containing the text values you set with `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. +`export_to()` can optionally record the text values you set with `set_cell()` +alongside the output, making 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. ```{r, eval = FALSE} -# Default: writes both output.pdf and output.pdf.json +# Default: just the PDF, no metadata is written export_to(gridify_obj, to = "output.pdf") -# Disable the sidecar -export_to(gridify_obj, to = "output.pdf", metadata = "none") +# Write a JSON sidecar (output.pdf.json) next to the output +export_to(gridify_obj, to = "output.pdf", metadata = "sidecar") # PDF only: embed the same JSON payload in the PDF /Title metadata # (no sidecar file is written in this mode) export_to(gridify_obj, to = "output.pdf", metadata = "embed") ``` +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 From 940f0ae35a6eae985d2e84f7f3e8a26fc4d49e8c Mon Sep 17 00:00:00 2001 From: Maciej Nasinski <Nasinski.maciej@gmail.com> Date: Mon, 11 May 2026 11:08:22 +0200 Subject: [PATCH 04/10] proper utils meta --- R/grid_utils.R | 131 ----------------------------- R/gridify-utils.R | 21 +++++ man/gpar_args.Rd | 8 +- man/gpar_call.Rd | 8 +- man/grid_unit_type.Rd | 8 +- man/is_flexible_grob.Rd | 32 ------- man/object_viewport_height_expr.Rd | 35 -------- man/resolve_export_metadata.Rd | 2 +- 8 files changed, 25 insertions(+), 220 deletions(-) delete mode 100644 R/grid_utils.R delete mode 100644 man/is_flexible_grob.Rd delete mode 100644 man/object_viewport_height_expr.Rd diff --git a/R/grid_utils.R b/R/grid_utils.R deleted file mode 100644 index fdc4bd2..0000000 --- a/R/grid_utils.R +++ /dev/null @@ -1,131 +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))) -} - -#' Detect a "flexible" grob whose natural height is not meaningful -#' -#' A flexible grob is one designed to fill whatever container it is placed -#' in, so `grid::grobHeight()` cannot be used to size its viewport. Two -#' shapes are recognised: -#' * gtables with at least one `null` unit in their `heights` — -#' e.g. `ggplot2::ggplotGrob()`. -#' * recorded gTrees produced by `grid::grid.grabExpr()` (used internally -#' for the `formula` input type via `gridGraphics::grid.echo()`); these -#' carry a `childrenvp` describing the recording viewport, outside of -#' which `grobHeight()` collapses to 0. -#' -#' All other grobs (`gt::as_gtable()`, `flextable::gen_grob()`, plain -#' `grid::rectGrob()` / `grid::nullGrob()`, ...) are treated as fixed-size. -#' -#' @param grob a grob. -#' @return `TRUE` if `grob` is flexible, `FALSE` otherwise. -#' @keywords internal -is_flexible_grob <- function(grob) { - if (inherits(grob, "gtable")) { - return(any(grid::unitType(grob$heights) == "null")) - } - if (inherits(grob, "gTree")) { - return(!is.null(grob$childrenvp)) - } - FALSE -} - -#' Build the viewport-height expression for the object's grob -#' -#' Chooses between the grob's natural height (`grid::grobHeight()`) and a -#' layout-driven height in npc, then floors the result at 1 inch via -#' `grid::unit.pmax()` so the viewport never collapses. -#' -#' The natural height is used only when the caller has opted into vertical -#' anchoring (`vjust != 0.5`) and the grob is not flexible -#' (see `is_flexible_grob()`). The `vjust == 0.5` case is preserved for -#' byte-for-byte backwards compatibility with the historical -#' "fill the row" behaviour. -#' -#' The returned expression references an unbound symbol `OBJECT`; the -#' caller is responsible for evaluating it in an environment that binds -#' `OBJECT` to the grob. -#' -#' @param grob a grob; used only for flexibility detection. -#' @param vjust numeric, the layout's object vjust. -#' @param height numeric, the layout's object height (in npc). -#' @return an unevaluated call producing a `grid::unit`. -#' @keywords internal -object_viewport_height_expr <- function(grob, vjust, height) { - natural_height <- if (vjust != 0.5 && !is_flexible_grob(grob)) { - quote(grid::grobHeight(OBJECT)) - } else { - substitute(grid::unit(h, "npc"), list(h = height)) - } - substitute( - grid::unit.pmax(NH, grid::unit(1, "inch")), - list(NH = natural_height) - ) -} - -#' 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"`, `"embed"`. -#' @keywords internal -resolve_export_metadata <- function(metadata) { - choices <- c("none", "sidecar", "embed") - if (is.null(metadata)) { - metadata <- getOption("gridify.export.metadata", "none") - } - match.arg(metadata, choices) -} diff --git a/R/gridify-utils.R b/R/gridify-utils.R index 425cdca..8322e5f 100644 --- a/R/gridify-utils.R +++ b/R/gridify-utils.R @@ -106,4 +106,25 @@ write_metadata_sidecar <- function(payload, to) { side <- paste0(to, ".json") writeLines(json, con = side, useBytes = TRUE) 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"`, `"embed"`. +#' @keywords internal +resolve_export_metadata <- function(metadata) { + choices <- c("none", "sidecar", "embed") + if (is.null(metadata)) { + metadata <- getOption("gridify.export.metadata", "none") + } + match.arg(metadata, choices) } \ No newline at end of file diff --git a/man/gpar_args.Rd b/man/gpar_args.Rd index 4caf59c..4dd8b1a 100644 --- a/man/gpar_args.Rd +++ b/man/gpar_args.Rd @@ -1,24 +1,18 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/gridify-utils.R, R/grid_utils.R +% Please edit documentation in R/gridify-utils.R \name{gpar_args} \alias{gpar_args} \title{Get \code{grid::gpar} arguments} \usage{ -gpar_args(gpar) - gpar_args(gpar) } \arguments{ \item{gpar}{a \code{grid::gpar} object.} } \value{ -a list. - a list. } \description{ -Get \code{grid::gpar} arguments - Get \code{grid::gpar} arguments } \keyword{internal} diff --git a/man/gpar_call.Rd b/man/gpar_call.Rd index 58e4dcc..e45d20b 100644 --- a/man/gpar_call.Rd +++ b/man/gpar_call.Rd @@ -1,24 +1,18 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/gridify-utils.R, 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} \usage{ -gpar_call(gpar) - gpar_call(gpar) } \arguments{ \item{gpar}{a \code{grid::gpar} object.} } \value{ -a call. - a call. } \description{ -Convert \code{grid::gpar} to a call - Convert \code{grid::gpar} to a call } \keyword{internal} diff --git a/man/grid_unit_type.Rd b/man/grid_unit_type.Rd index 2bf01ab..0c7a070 100644 --- a/man/grid_unit_type.Rd +++ b/man/grid_unit_type.Rd @@ -1,11 +1,9 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/gridify-utils.R, 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} \usage{ -grid_unit_type(x, use_grid = TRUE) - grid_unit_type(x, use_grid = TRUE) } \arguments{ @@ -16,13 +14,9 @@ The main purpose of this argument is to have full test coverage in tests. Default TRUE.} } \value{ -a character vector with unit type for each element. - a character vector with unit type for each element. } \description{ -Wrapper for \code{grid::unitType} which supports older R versions - Wrapper for \code{grid::unitType} which supports older R versions } \keyword{internal} diff --git a/man/is_flexible_grob.Rd b/man/is_flexible_grob.Rd deleted file mode 100644 index 7f38045..0000000 --- a/man/is_flexible_grob.Rd +++ /dev/null @@ -1,32 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/grid_utils.R -\name{is_flexible_grob} -\alias{is_flexible_grob} -\title{Detect a "flexible" grob whose natural height is not meaningful} -\usage{ -is_flexible_grob(grob) -} -\arguments{ -\item{grob}{a grob.} -} -\value{ -\code{TRUE} if \code{grob} is flexible, \code{FALSE} otherwise. -} -\description{ -A flexible grob is one designed to fill whatever container it is placed -in, so \code{grid::grobHeight()} cannot be used to size its viewport. Two -shapes are recognised: -\itemize{ -\item gtables with at least one \code{null} unit in their \code{heights} — -e.g. \code{ggplot2::ggplotGrob()}. -\item recorded gTrees produced by \code{grid::grid.grabExpr()} (used internally -for the \code{formula} input type via \code{gridGraphics::grid.echo()}); these -carry a \code{childrenvp} describing the recording viewport, outside of -which \code{grobHeight()} collapses to 0. -} -} -\details{ -All other grobs (\code{gt::as_gtable()}, \code{flextable::gen_grob()}, plain -\code{grid::rectGrob()} / \code{grid::nullGrob()}, ...) are treated as fixed-size. -} -\keyword{internal} diff --git a/man/object_viewport_height_expr.Rd b/man/object_viewport_height_expr.Rd deleted file mode 100644 index 7bfc83c..0000000 --- a/man/object_viewport_height_expr.Rd +++ /dev/null @@ -1,35 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/grid_utils.R -\name{object_viewport_height_expr} -\alias{object_viewport_height_expr} -\title{Build the viewport-height expression for the object's grob} -\usage{ -object_viewport_height_expr(grob, vjust, height) -} -\arguments{ -\item{grob}{a grob; used only for flexibility detection.} - -\item{vjust}{numeric, the layout's object vjust.} - -\item{height}{numeric, the layout's object height (in npc).} -} -\value{ -an unevaluated call producing a \code{grid::unit}. -} -\description{ -Chooses between the grob's natural height (\code{grid::grobHeight()}) and a -layout-driven height in npc, then floors the result at 1 inch via -\code{grid::unit.pmax()} so the viewport never collapses. -} -\details{ -The natural height is used only when the caller has opted into vertical -anchoring (\code{vjust != 0.5}) and the grob is not flexible -(see \code{is_flexible_grob()}). The \code{vjust == 0.5} case is preserved for -byte-for-byte backwards compatibility with the historical -"fill the row" behaviour. - -The returned expression references an unbound symbol \code{OBJECT}; the -caller is responsible for evaluating it in an environment that binds -\code{OBJECT} to the grob. -} -\keyword{internal} diff --git a/man/resolve_export_metadata.Rd b/man/resolve_export_metadata.Rd index 1ca97d8..7d411a0 100644 --- a/man/resolve_export_metadata.Rd +++ b/man/resolve_export_metadata.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{resolve_export_metadata} \alias{resolve_export_metadata} \title{Resolve the effective \code{metadata} argument for \code{export_to()}} From c90575a15a7e7c94bcd6fb240e8bc5bdba184458 Mon Sep 17 00:00:00 2001 From: Maciej Nasinski <Nasinski.maciej@gmail.com> Date: Thu, 21 May 2026 11:53:20 +0200 Subject: [PATCH 05/10] improve the PR - simplify and no deps --- DESCRIPTION | 2 +- NEWS.md | 9 ++++---- R/gridify-methods.R | 21 ----------------- R/gridify-utils.R | 20 +++++++++------- man/export_to.Rd | 4 ---- man/resolve_export_metadata.Rd | 2 +- tests/testthat/test_export_to.R | 33 ++------------------------- tests/testthat/test_gridify_to_json.R | 4 ++++ vignettes/multi_page_examples.Rmd | 19 ++++++++++----- vignettes/simple_examples.Rmd | 4 ---- 10 files changed, 38 insertions(+), 80 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 735c6d2..804a8b6 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -27,10 +27,10 @@ Roxygen: list(markdown = TRUE) Imports: grDevices, grid, - jsonlite, methods Suggests: flextable (>= 0.8.0), + jsonlite, ggplot2, gridGraphics, gt (>= 0.11.0), diff --git a/NEWS.md b/NEWS.md index f817dca..502834e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,10 +2,11 @@ * `export_to()` gains a `metadata` argument that records `set_cell()` text values alongside the exported output. The default is `metadata = "none"`; pass - `"sidecar"` to write a JSON sidecar `<file>.json` next to the output, or - `"embed"` (PDF only) to inject the same JSON payload as the PDF `/Title` - so the metadata travels inside the file. The default can be changed - project-wide by setting `options(gridify.export.metadata = "sidecar")`. + `"sidecar"` to write a JSON sidecar `<file>.json` next to the output. + The default can be changed project-wide by setting + `options(gridify.export.metadata = "sidecar")`. +* `jsonlite` moved from `Imports` to `Suggests`; it is only required when using + `metadata = "sidecar"`. # gridify 0.7.7 diff --git a/R/gridify-methods.R b/R/gridify-methods.R index a928b74..eb09f85 100644 --- a/R/gridify-methods.R +++ b/R/gridify-methods.R @@ -948,10 +948,6 @@ setMethod("show", "gridifyLayout", function(object) { #' containing a named list mapping cell name to its text value (or, for a multi-page PDF #' built from a list of objects, a JSON array of such named lists, one per page). #' No file is written when no cells were set. -#' \item `"embed"` - PDF only. Encode the same payload as JSON and pass it as the -#' `title` argument of the PDF graphics device, embedding it in the PDF `/Title` -#' metadata. The user-supplied `title` (via `...`) takes precedence and disables -#' the injection. No sidecar file is written. #' \item `"none"` (default) - do not produce any metadata. #' } #' Validated with [match.arg()] so it can be abbreviated. @@ -1185,14 +1181,6 @@ setMethod( dev_args <- utils::modifyList(default_args, user_args) dev_args$file <- to - if ( - metadata == "embed" && - length(payload) > 0 && - is.null(dev_args$title) - ) { - dev_args$title <- gridify_to_json(payload) - } - if (is.null(device)) { device <- grDevices::pdf } @@ -1288,15 +1276,6 @@ setMethod( list(file = to, width = 11.69, height = 8.27, onefile = TRUE), user_args ) - if ( - metadata == "embed" && - length(payload) > 0 && - any(lengths(payload) > 0) && - is.null(dev_args$title) - ) { - dev_args$title <- gridify_to_json(payload) - } - do.call(device, dev_args) on.exit(grDevices::dev.off(), add = TRUE) diff --git a/R/gridify-utils.R b/R/gridify-utils.R index 8322e5f..a7ccdc6 100644 --- a/R/gridify-utils.R +++ b/R/gridify-utils.R @@ -74,12 +74,16 @@ gridify_metadata <- function(x) { #' @return a length-one character vector with the JSON representation of `x`. #' @keywords internal gridify_to_json <- function(x) { - as.character(jsonlite::toJSON( - x, - auto_unbox = TRUE, - null = "null", - na = "null" - )) + 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") + } } #' Write the JSON metadata sidecar file @@ -119,10 +123,10 @@ write_metadata_sidecar <- function(payload, to) { #' [match.arg()], so abbreviations are accepted. #' #' @param metadata the value passed by the user; may be `NULL`. -#' @return one of `"none"`, `"sidecar"`, `"embed"`. +#' @return one of `"none"`, `"sidecar"`. #' @keywords internal resolve_export_metadata <- function(metadata) { - choices <- c("none", "sidecar", "embed") + choices <- c("none", "sidecar") if (is.null(metadata)) { metadata <- getOption("gridify.export.metadata", "none") } diff --git a/man/export_to.Rd b/man/export_to.Rd index d64ac70..991a5f1 100644 --- a/man/export_to.Rd +++ b/man/export_to.Rd @@ -31,10 +31,6 @@ One of: containing a named list mapping cell name to its text value (or, for a multi-page PDF built from a list of objects, a JSON array of such named lists, one per page). No file is written when no cells were set. -\item \code{"embed"} - PDF only. Encode the same payload as JSON and pass it as the -\code{title} argument of the PDF graphics device, embedding it in the PDF \verb{/Title} -metadata. The user-supplied \code{title} (via \code{...}) takes precedence and disables -the injection. No sidecar file is written. \item \code{"none"} (default) - do not produce any metadata. } Validated with \code{\link[=match.arg]{match.arg()}} so it can be abbreviated. diff --git a/man/resolve_export_metadata.Rd b/man/resolve_export_metadata.Rd index 7d411a0..6e8a067 100644 --- a/man/resolve_export_metadata.Rd +++ b/man/resolve_export_metadata.Rd @@ -10,7 +10,7 @@ resolve_export_metadata(metadata) \item{metadata}{the value passed by the user; may be \code{NULL}.} } \value{ -one of \code{"none"}, \code{"sidecar"}, \code{"embed"}. +one of \code{"none"}, \code{"sidecar"}. } \description{ Resolves the \code{metadata} argument from (in order of precedence): diff --git a/tests/testthat/test_export_to.R b/tests/testthat/test_export_to.R index 515f836..c5fce00 100644 --- a/tests/testthat/test_export_to.R +++ b/tests/testthat/test_export_to.R @@ -215,6 +215,7 @@ test_that("default metadata writes no sidecar (option unset)", { }) 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")) { @@ -294,38 +295,8 @@ test_that("metadata writes no sidecar when no cells are set", { expect_false(file.exists(side)) }) -test_that("metadata = 'embed' injects PDF /Title and skips sidecar", { - x <- mock_gridify_with_cells() - out_file <- file.path(tempdir(), "meta_embed.pdf") - side <- paste0(out_file, ".json") - if (file.exists(side)) file.remove(side) - - expect_no_error(export_to(x, out_file, metadata = "embed")) - expect_true(file.exists(out_file)) - expect_false(file.exists(side)) - - raw_bytes <- readBin(out_file, what = "raw", n = file.size(out_file)) - pdf_bytes <- rawToChar(raw_bytes[raw_bytes != as.raw(0)]) - expect_true(grepl("header_left_1", pdf_bytes, fixed = TRUE, useBytes = TRUE)) -}) - -test_that("metadata = 'embed' respects user-supplied title", { - x <- mock_gridify_with_cells() - out_file <- file.path(tempdir(), "meta_embed_user_title.pdf") - - expect_no_error(export_to( - x, - out_file, - metadata = "embed", - title = "MY TITLE" - )) - raw_bytes <- readBin(out_file, what = "raw", n = file.size(out_file)) - pdf_bytes <- rawToChar(raw_bytes[raw_bytes != as.raw(0)]) - expect_true(grepl("MY TITLE", pdf_bytes, fixed = TRUE, useBytes = TRUE)) - expect_false(grepl("header_left_1", pdf_bytes, fixed = TRUE, useBytes = TRUE)) -}) - test_that("metadata sidecar for multi-page PDF is a JSON array", { + 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") diff --git a/tests/testthat/test_gridify_to_json.R b/tests/testthat/test_gridify_to_json.R index 09cb33a..67b55b0 100644 --- a/tests/testthat/test_gridify_to_json.R +++ b/tests/testthat/test_gridify_to_json.R @@ -1,4 +1,5 @@ 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") @@ -16,6 +17,7 @@ test_that("gridify_to_json unboxes scalars", { }) 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) @@ -35,6 +37,7 @@ test_that("gridify_metadata returns empty list for no cells", { }) test_that("write_metadata_sidecar writes JSON file and returns its path", { + skip_if_not_installed("jsonlite") base <- tempfile(fileext = ".pdf") side <- paste0(base, ".json") if (file.exists(side)) file.remove(side) @@ -58,6 +61,7 @@ test_that("write_metadata_sidecar skips empty payloads", { }) test_that("write_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) diff --git a/vignettes/multi_page_examples.Rmd b/vignettes/multi_page_examples.Rmd index b932346..4a558b4 100644 --- a/vignettes/multi_page_examples.Rmd +++ b/vignettes/multi_page_examples.Rmd @@ -347,12 +347,19 @@ export_to( ) ``` -For multi-page PDFs, passing `metadata = "sidecar"` writes a JSON sidecar -(`<file>.pdf.json`) containing a JSON array with one object per page, listing -the `set_cell()` text values for each page. Pass `metadata = "embed"` to -encode the array in the PDF `/Title` instead. The default is `"none"` (no -metadata). Set `options(gridify.export.metadata = "sidecar")` to change the -default project-wide. +Passing `metadata = "sidecar"` to `export_to()` writes a JSON sidecar next to +each output file. The exact content 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 a JSON array with one object per + page, listing the `set_cell()` text values for that page. +- **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 a single JSON object. + +The default is `"none"` (no metadata). Set +`options(gridify.export.metadata = "sidecar")` to change the default +project-wide. ### Extending The Multi-Page Example diff --git a/vignettes/simple_examples.Rmd b/vignettes/simple_examples.Rmd index d0ad8e2..8cbf728 100644 --- a/vignettes/simple_examples.Rmd +++ b/vignettes/simple_examples.Rmd @@ -778,10 +778,6 @@ 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") - -# PDF only: embed the same JSON payload in the PDF /Title metadata -# (no sidecar file is written in this mode) -export_to(gridify_obj, to = "output.pdf", metadata = "embed") ``` To enable metadata writing globally for a project, set the From dd8229289d29afb35bcca029787ba4a3402b689d Mon Sep 17 00:00:00 2001 From: Maciej Nasinski <nasinski.maciej@gmail.com> Date: Tue, 26 May 2026 20:40:24 +0200 Subject: [PATCH 06/10] Disable old R versions in workflow Comment out old R versions in CI configuration --- .github/workflows/R-CMD-check.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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")' From e849921a29904282cdaa6026e142190dedf65934 Mon Sep 17 00:00:00 2001 From: Maciej Nasinski <Nasinski.maciej@gmail.com> Date: Wed, 27 May 2026 13:55:48 +0200 Subject: [PATCH 07/10] Huge improvements - better json structure and prevent outdated json --- NEWS.md | 4 ++ R/gridify-methods.R | 32 +++++++----- R/gridify-utils.R | 68 ++++++++++++++++++------- man/export_to.Rd | 10 ++-- man/has_metadata_payload.Rd | 18 +++++++ man/metadata_sidecar_payload.Rd | 20 ++++++++ man/sync_metadata_sidecar.Rd | 23 +++++++++ tests/testthat/test_export_to.R | 46 ++++++++++++++--- tests/testthat/test_gridify_to_json.R | 71 ++++++++++++++++++++------- vignettes/multi_page_examples.Rmd | 34 +++++++++++-- vignettes/simple_examples.Rmd | 24 ++++++++- 11 files changed, 284 insertions(+), 66 deletions(-) create mode 100644 man/has_metadata_payload.Rd create mode 100644 man/metadata_sidecar_payload.Rd create mode 100644 man/sync_metadata_sidecar.Rd diff --git a/NEWS.md b/NEWS.md index 1d63e42..5a7003c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -5,6 +5,10 @@ * `export_to()` gains a `metadata` argument that records `set_cell()` text values alongside the exported output. The default is `metadata = "none"`; pass `"sidecar"` to write a JSON sidecar `<file>.json` next to the output. + The sidecar uses a schema-versioned `pages` structure for both single-page + and multi-page exports. + Re-exporting the same output without metadata, or with no metadata values, + removes any stale sidecar for that output. The default can be changed project-wide by setting `options(gridify.export.metadata = "sidecar")`. * `jsonlite` moved from `Imports` to `Suggests`; it is only required when using diff --git a/R/gridify-methods.R b/R/gridify-methods.R index eb09f85..523669a 100644 --- a/R/gridify-methods.R +++ b/R/gridify-methods.R @@ -945,10 +945,12 @@ setMethod("show", "gridifyLayout", function(object) { #' One of: #' \itemize{ #' \item `"sidecar"` - write a JSON sidecar file next to the output named `<to>.json` -#' containing a named list mapping cell name to its text value (or, for a multi-page PDF -#' built from a list of objects, a JSON array of such named lists, one per page). -#' No file is written when no cells were set. -#' \item `"none"` (default) - do not produce any metadata. +#' containing `schema_version` and `pages`. 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 cells were set. +#' \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 @@ -1175,6 +1177,11 @@ setMethod( 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) @@ -1188,9 +1195,7 @@ setMethod( do.call(device, dev_args) on.exit(grDevices::dev.off(), add = TRUE) print(x) - if (metadata == "sidecar") { - write_metadata_sidecar(payload, to) - } + 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) @@ -1212,9 +1217,7 @@ setMethod( on.exit(grDevices::dev.off(), add = TRUE) grid::grid.newpage() print(x) - if (metadata == "sidecar") { - write_metadata_sidecar(payload, to) - } + sync_metadata_sidecar(to, sidecar_json) } }) @@ -1270,6 +1273,11 @@ setMethod( } 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( @@ -1283,9 +1291,7 @@ setMethod( print(obj) } - if (metadata == "sidecar") { - write_metadata_sidecar(payload, to) - } + 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." diff --git a/R/gridify-utils.R b/R/gridify-utils.R index a7ccdc6..b1fd273 100644 --- a/R/gridify-utils.R +++ b/R/gridify-utils.R @@ -86,29 +86,61 @@ gridify_to_json <- function(x) { } } -#' Write the JSON metadata sidecar file +#' Build the JSON sidecar metadata structure #' -#' Encodes the metadata `payload` as JSON via [gridify_to_json()] and writes it -#' to `paste0(to, ".json")`. The file is written with `useBytes = TRUE` so the -#' UTF-8 bytes produced by `jsonlite::toJSON()` are preserved verbatim. -#' If `payload` is `NULL` or empty no file is created and `NULL` is returned -#' (this keeps `export_to()` from producing empty sidecars when no cells were -#' set). +#' 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 to serialise. -#' @param to A length-one character string with the path of the main output -#' file. The sidecar path is `paste0(to, ".json")`. -#' @return Invisibly, the path of the sidecar file that was written, or `NULL` -#' when no file was written. +#' @param payload A named list (single page) or list of named lists (multi-page) +#' of metadata values. +#' @return A named list containing `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_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 -write_metadata_sidecar <- function(payload, to) { - if (length(payload) == 0) { - return(invisible(NULL)) +has_metadata_payload <- function(payload) { + if (is.null(payload) || length(payload) == 0) { + return(FALSE) } - json <- gridify_to_json(payload) + 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") - writeLines(json, con = side, useBytes = TRUE) + if (!is.null(json)) { + writeLines(json, con = side, useBytes = TRUE) + } else if (file.exists(side)) { + unlink(side) + } invisible(side) } diff --git a/man/export_to.Rd b/man/export_to.Rd index 991a5f1..fa517aa 100644 --- a/man/export_to.Rd +++ b/man/export_to.Rd @@ -28,10 +28,12 @@ By default a file name extension is used to choose a graphics device function. D One of: \itemize{ \item \code{"sidecar"} - write a JSON sidecar file next to the output named \verb{<to>.json} -containing a named list mapping cell name to its text value (or, for a multi-page PDF -built from a list of objects, a JSON array of such named lists, one per page). -No file is written when no cells were set. -\item \code{"none"} (default) - do not produce any metadata. +containing \code{schema_version} and \code{pages}. 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 cells were set. +\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 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..4b15602 --- /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_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/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 c5fce00..6b5cc0b 100644 --- a/tests/testthat/test_export_to.R +++ b/tests/testthat/test_export_to.R @@ -227,10 +227,12 @@ test_that("metadata = 'sidecar' writes JSON sidecar for PDF and PNG", { expect_true(file.exists(out_file)) expect_true(file.exists(side)) - parsed <- jsonlite::fromJSON(side) - expect_identical(parsed$header_left_1, "My Company") - expect_identical(parsed$title_1, "<Title 1>") - expect_identical(parsed$watermark, "DRAFT \"x\" \\ y\nz") + parsed <- jsonlite::fromJSON(side, simplifyVector = FALSE) + 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") } }) @@ -284,6 +286,20 @@ test_that("metadata = 'none' writes no sidecar", { expect_false(file.exists(side)) }) +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") @@ -295,7 +311,20 @@ test_that("metadata writes no sidecar when no cells are set", { expect_false(file.exists(side)) }) -test_that("metadata sidecar for multi-page PDF is a JSON array", { +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") @@ -306,9 +335,10 @@ test_that("metadata sidecar for multi-page PDF is a JSON array", { expect_true(file.exists(side)) parsed <- jsonlite::fromJSON(side, simplifyVector = FALSE) - expect_length(parsed, 2) - expect_identical(parsed[[1]]$header_left_1, "My Company") - expect_identical(parsed[[2]]$header_left_1, "My Company") + 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", { diff --git a/tests/testthat/test_gridify_to_json.R b/tests/testthat/test_gridify_to_json.R index 67b55b0..aec7f9d 100644 --- a/tests/testthat/test_gridify_to_json.R +++ b/tests/testthat/test_gridify_to_json.R @@ -36,41 +36,76 @@ test_that("gridify_metadata returns empty list for no cells", { expect_identical(gridify_metadata(obj), stats::setNames(list(), character(0))) }) -test_that("write_metadata_sidecar writes JSON file and returns its path", { +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_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_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"))) - payload <- list(a = "x", b = "y") - res <- write_metadata_sidecar(payload, base) - - expect_identical(res, side) + expect_identical(sync_metadata_sidecar(base, json), side) expect_true(file.exists(side)) - expect_identical(jsonlite::fromJSON(side), payload) + parsed <- jsonlite::fromJSON(side, simplifyVector = FALSE) + expect_identical(parsed$schema_version, "1.0.0") + expect_identical(parsed$pages[[1]]$cells$a, "x") }) -test_that("write_metadata_sidecar skips empty payloads", { +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) - expect_null(write_metadata_sidecar(list(), base)) - expect_null(write_metadata_sidecar(NULL, base)) - expect_false(file.exists(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_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("write_metadata_sidecar serialises multi-page list payload", { - skip_if_not_installed("jsonlite") +test_that("sync_metadata_sidecar uses pre-encoded JSON", { base <- tempfile(fileext = ".pdf") side <- paste0(base, ".json") if (file.exists(side)) file.remove(side) - payload <- list(list(a = "1"), list(a = "2")) - write_metadata_sidecar(payload, base) + expect_identical(sync_metadata_sidecar(base, "{\"a\":\"y\"}"), side) + expect_identical(readLines(side, warn = FALSE), "{\"a\":\"y\"}") +}) - parsed <- jsonlite::fromJSON(side, simplifyVector = FALSE) - expect_length(parsed, 2) - expect_identical(parsed[[1]]$a, "1") - expect_identical(parsed[[2]]$a, "2") +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 4a558b4..90ee133 100644 --- a/vignettes/multi_page_examples.Rmd +++ b/vignettes/multi_page_examples.Rmd @@ -348,19 +348,45 @@ export_to( ``` Passing `metadata = "sidecar"` to `export_to()` writes a JSON sidecar next to -each output file. The exact content depends on how the output is structured: +each output file. The sidecar uses the same schema-versioned `pages` structure +for single-page and multi-page exports: + +```json +{ + "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 a JSON array with one object per - page, listing the `set_cell()` text values for that page. + 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 a single JSON object. + `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 +`set_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 8cbf728..fa35e0b 100644 --- a/vignettes/simple_examples.Rmd +++ b/vignettes/simple_examples.Rmd @@ -770,7 +770,8 @@ export_to(gridify_obj, to = "output.jpeg", width = 2400, height = 1800, res = 30 `export_to()` can optionally record the text values you set with `set_cell()` alongside the output, making 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 feature is opt-in via the `metadata` argument. The sidecar 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 @@ -780,6 +781,27 @@ export_to(gridify_obj, to = "output.pdf") export_to(gridify_obj, to = "output.pdf", metadata = "sidecar") ``` +For a single-page export, the JSON sidecar has one page entry: + +```json +{ + "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 `set_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`): From fa44a14e487b2ea84e31eacf860035cba1352baf Mon Sep 17 00:00:00 2001 From: Maciej Nasinski <Nasinski.maciej@gmail.com> Date: Thu, 28 May 2026 13:25:42 +0200 Subject: [PATCH 08/10] improve the structure --- NEWS.md | 4 ++-- R/gridify-methods.R | 9 +++++---- R/gridify-utils.R | 3 ++- man/export_to.Rd | 9 +++++---- man/metadata_sidecar_payload.Rd | 2 +- man/write_metadata_sidecar.Rd | 28 --------------------------- tests/testthat/test_export_to.R | 2 ++ tests/testthat/test_gridify_to_json.R | 4 ++++ vignettes/multi_page_examples.Rmd | 6 ++++-- vignettes/simple_examples.Rmd | 6 ++++-- 10 files changed, 29 insertions(+), 44 deletions(-) delete mode 100644 man/write_metadata_sidecar.Rd diff --git a/NEWS.md b/NEWS.md index 5a7003c..e4d22a3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -5,8 +5,8 @@ * `export_to()` gains a `metadata` argument that records `set_cell()` text values alongside the exported output. The default is `metadata = "none"`; pass `"sidecar"` to write a JSON sidecar `<file>.json` next to the output. - The sidecar uses a schema-versioned `pages` structure for both single-page - and multi-page exports. + 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 metadata values, removes any stale sidecar for that output. The default can be changed project-wide by setting diff --git a/R/gridify-methods.R b/R/gridify-methods.R index 523669a..db903aa 100644 --- a/R/gridify-methods.R +++ b/R/gridify-methods.R @@ -945,10 +945,11 @@ setMethod("show", "gridifyLayout", function(object) { #' One of: #' \itemize{ #' \item `"sidecar"` - write a JSON sidecar file next to the output named `<to>.json` -#' containing `schema_version` and `pages`. 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 cells were set. +#' 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 cells were set. #' \item `"none"` (default) - do not produce any metadata and remove any existing #' sidecar for the same output file. #' } diff --git a/R/gridify-utils.R b/R/gridify-utils.R index b1fd273..b5b6946 100644 --- a/R/gridify-utils.R +++ b/R/gridify-utils.R @@ -93,7 +93,7 @@ gridify_to_json <- function(x) { #' #' @param payload A named list (single page) or list of named lists (multi-page) #' of metadata values. -#' @return A named list containing `schema_version` and `pages`. +#' @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))) { @@ -103,6 +103,7 @@ metadata_sidecar_payload <- function(payload) { } list( + schema = "gridify.sidecar.metadata", schema_version = "1.0.0", pages = lapply(pages, function(cells) list(cells = cells)) ) diff --git a/man/export_to.Rd b/man/export_to.Rd index fa517aa..c018844 100644 --- a/man/export_to.Rd +++ b/man/export_to.Rd @@ -28,10 +28,11 @@ By default a file name extension is used to choose a graphics device function. D One of: \itemize{ \item \code{"sidecar"} - write a JSON sidecar file next to the output named \verb{<to>.json} -containing \code{schema_version} and \code{pages}. 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 cells were set. +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 cells were set. \item \code{"none"} (default) - do not produce any metadata and remove any existing sidecar for the same output file. } diff --git a/man/metadata_sidecar_payload.Rd b/man/metadata_sidecar_payload.Rd index 4b15602..975d0be 100644 --- a/man/metadata_sidecar_payload.Rd +++ b/man/metadata_sidecar_payload.Rd @@ -11,7 +11,7 @@ metadata_sidecar_payload(payload) of metadata values.} } \value{ -A named list containing \code{schema_version} and \code{pages}. +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 diff --git a/man/write_metadata_sidecar.Rd b/man/write_metadata_sidecar.Rd deleted file mode 100644 index 3c581ce..0000000 --- a/man/write_metadata_sidecar.Rd +++ /dev/null @@ -1,28 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/gridify-utils.R -\name{write_metadata_sidecar} -\alias{write_metadata_sidecar} -\title{Write the JSON metadata sidecar file} -\usage{ -write_metadata_sidecar(payload, to) -} -\arguments{ -\item{payload}{A named list (single page) or list of named lists -(multi-page) of metadata values to serialise.} - -\item{to}{A length-one character string with the path of the main output -file. The sidecar path is \code{paste0(to, ".json")}.} -} -\value{ -Invisibly, the path of the sidecar file that was written, or \code{NULL} -when no file was written. -} -\description{ -Encodes the metadata \code{payload} as JSON via \code{\link[=gridify_to_json]{gridify_to_json()}} and writes it -to \code{paste0(to, ".json")}. The file is written with \code{useBytes = TRUE} so the -UTF-8 bytes produced by \code{jsonlite::toJSON()} are preserved verbatim. -If \code{payload} is \code{NULL} or empty no file is created and \code{NULL} is returned -(this keeps \code{export_to()} from producing empty sidecars when no cells were -set). -} -\keyword{internal} diff --git a/tests/testthat/test_export_to.R b/tests/testthat/test_export_to.R index 6b5cc0b..54835e1 100644 --- a/tests/testthat/test_export_to.R +++ b/tests/testthat/test_export_to.R @@ -228,6 +228,7 @@ test_that("metadata = 'sidecar' writes JSON sidecar for PDF and PNG", { 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") @@ -335,6 +336,7 @@ test_that("metadata sidecar for multi-page PDF uses pages schema", { 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") diff --git a/tests/testthat/test_gridify_to_json.R b/tests/testthat/test_gridify_to_json.R index aec7f9d..31a5ed9 100644 --- a/tests/testthat/test_gridify_to_json.R +++ b/tests/testthat/test_gridify_to_json.R @@ -46,11 +46,13 @@ test_that("has_metadata_payload detects populated payloads", { 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")) @@ -67,6 +69,7 @@ test_that("sync_metadata_sidecar writes populated sidecars", { 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") }) @@ -81,6 +84,7 @@ test_that("sync_metadata_sidecar serialises multi-page list payload", { 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") diff --git a/vignettes/multi_page_examples.Rmd b/vignettes/multi_page_examples.Rmd index 90ee133..b95cf06 100644 --- a/vignettes/multi_page_examples.Rmd +++ b/vignettes/multi_page_examples.Rmd @@ -348,11 +348,13 @@ export_to( ``` Passing `metadata = "sidecar"` to `export_to()` writes a JSON sidecar next to -each output file. The sidecar uses the same schema-versioned `pages` structure -for single-page and multi-page exports: +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: ```json { + "schema": "gridify.sidecar.metadata", "schema_version": "1.0.0", "pages": [ { diff --git a/vignettes/simple_examples.Rmd b/vignettes/simple_examples.Rmd index fa35e0b..6261f63 100644 --- a/vignettes/simple_examples.Rmd +++ b/vignettes/simple_examples.Rmd @@ -770,8 +770,9 @@ export_to(gridify_obj, to = "output.jpeg", width = 2400, height = 1800, res = 30 `export_to()` can optionally record the text values you set with `set_cell()` alongside the output, making 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 uses the same -schema-versioned `pages` structure for single-page and multi-page exports. +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 @@ -785,6 +786,7 @@ For a single-page export, the JSON sidecar has one page entry: ```json { + "schema": "gridify.sidecar.metadata", "schema_version": "1.0.0", "pages": [ { From 0da061c45b17c53e9d77845a3825254fb9c7ca2d Mon Sep 17 00:00:00 2001 From: Maciej Nasinski <nasinski.maciej@gmail.com> Date: Thu, 28 May 2026 13:34:40 +0200 Subject: [PATCH 09/10] Update NEWS.md with changes and bug fixes --- NEWS.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index e4d22a3..90bd037 100644 --- a/NEWS.md +++ b/NEWS.md @@ -11,8 +11,6 @@ removes any stale sidecar for that output. The default can be changed project-wide by setting `options(gridify.export.metadata = "sidecar")`. -* `jsonlite` moved from `Imports` to `Suggests`; it is only required when using - `metadata = "sidecar"`. * Added support for `fill_empty = NA` in the `paginate_table()` function. ## Bug fixes From 2c04b10de4f6a6fb1f247ad3afa9e5e97bcb6dc8 Mon Sep 17 00:00:00 2001 From: Maciej Nasinski <Nasinski.maciej@gmail.com> Date: Thu, 28 May 2026 13:51:56 +0200 Subject: [PATCH 10/10] take into account defaults --- NEWS.md | 5 +++-- R/gridify-methods.R | 5 +++-- R/gridify-utils.R | 19 +++++++++++++------ inst/WORDLIST | 7 ++++--- man/export_to.Rd | 5 +++-- man/gridify_metadata.Rd | 7 ++++--- tests/testthat/test_export_to.R | 15 +++++++++++++++ tests/testthat/test_gridify_to_json.R | 19 ++++++++++++++++--- vignettes/multi_page_examples.Rmd | 5 +++-- vignettes/simple_examples.Rmd | 11 ++++++----- 10 files changed, 70 insertions(+), 28 deletions(-) diff --git a/NEWS.md b/NEWS.md index 90bd037..3ac6a0a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,12 +2,13 @@ ## New features -* `export_to()` gains a `metadata` argument that records `set_cell()` text values +* `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 `<file>.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 metadata values, + 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")`. diff --git a/R/gridify-methods.R b/R/gridify-methods.R index db903aa..c54865f 100644 --- a/R/gridify-methods.R +++ b/R/gridify-methods.R @@ -941,7 +941,8 @@ 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 `set_cell()` text values. +#' @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 `<to>.json` @@ -949,7 +950,7 @@ setMethod("show", "gridifyLayout", function(object) { #' `"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 cells were set. +#' 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. #' } diff --git a/R/gridify-utils.R b/R/gridify-utils.R index b5b6946..a210839 100644 --- a/R/gridify-utils.R +++ b/R/gridify-utils.R @@ -48,19 +48,26 @@ gpar_call <- function(gpar) { as.call(c(quote(grid::gpar), gpar_args(gpar))) } -#' Build the metadata payload for a `gridifyClass` object. +#' Build the metadata payload for a `gridifyClass` object #' -#' Extracts the `text` field from each `set_cell()` element, in the order they -#' were added. Cells with `NULL` text are skipped. +#' 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) { - elems <- x@elements - if (length(elems) == 0) { + cells <- x@layout@cells@cells + if (length(cells) == 0) { return(stats::setNames(list(), character(0))) } - texts <- lapply(elems, function(el) el[["text"]]) + 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))] } 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 c018844..1e7e3b4 100644 --- a/man/export_to.Rd +++ b/man/export_to.Rd @@ -24,7 +24,8 @@ 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 \code{set_cell()} text values. +\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{<to>.json} @@ -32,7 +33,7 @@ containing \code{schema}, \code{schema_version} and \code{pages}. The \code{sche \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 cells were set. +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. } diff --git a/man/gridify_metadata.Rd b/man/gridify_metadata.Rd index 4e7205f..1828e71 100644 --- a/man/gridify_metadata.Rd +++ b/man/gridify_metadata.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/gridify-utils.R \name{gridify_metadata} \alias{gridify_metadata} -\title{Build the metadata payload for a \code{gridifyClass} object.} +\title{Build the metadata payload for a \code{gridifyClass} object} \usage{ gridify_metadata(x) } @@ -13,7 +13,8 @@ gridify_metadata(x) a named list mapping cell name to its text value. } \description{ -Extracts the \code{text} field from each \code{set_cell()} element, in the order they -were added. Cells with \code{NULL} text are skipped. +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/tests/testthat/test_export_to.R b/tests/testthat/test_export_to.R index 54835e1..7305d40 100644 --- a/tests/testthat/test_export_to.R +++ b/tests/testthat/test_export_to.R @@ -287,6 +287,21 @@ test_that("metadata = 'none' writes no sidecar", { 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") diff --git a/tests/testthat/test_gridify_to_json.R b/tests/testthat/test_gridify_to_json.R index 31a5ed9..8706a53 100644 --- a/tests/testthat/test_gridify_to_json.R +++ b/tests/testthat/test_gridify_to_json.R @@ -23,15 +23,28 @@ test_that("gridify_to_json escapes special characters", { expect_identical(jsonlite::fromJSON(json)$w, s) }) -test_that("gridify_metadata extracts only set_cell text values", { +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", title_1 = "T1")) + 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 for no cells", { +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))) }) diff --git a/vignettes/multi_page_examples.Rmd b/vignettes/multi_page_examples.Rmd index b95cf06..cbf2baa 100644 --- a/vignettes/multi_page_examples.Rmd +++ b/vignettes/multi_page_examples.Rmd @@ -350,7 +350,8 @@ 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: +multi-page exports. It records effective cell text values, including layout +defaults and values supplied via `set_cell()`: ```json { @@ -386,7 +387,7 @@ 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 -`set_cell()` text values, removes any existing sidecar for that output. This +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 diff --git a/vignettes/simple_examples.Rmd b/vignettes/simple_examples.Rmd index 6261f63..4b5b803 100644 --- a/vignettes/simple_examples.Rmd +++ b/vignettes/simple_examples.Rmd @@ -767,9 +767,10 @@ export_to(gridify_obj, to = "output.jpeg", width = 2400, height = 1800, res = 30 ### Metadata -`export_to()` can optionally record the text values you set with `set_cell()` -alongside the output, making it easy to track which header, footer or -watermark text was used to produce a given figure without parsing the file. +`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. @@ -800,8 +801,8 @@ For a single-page export, the JSON sidecar has one page entry: ``` If the same output path is exported later with `metadata = "none"`, or with -`metadata = "sidecar"` but no `set_cell()` text values, any existing sidecar for -that output is removed. This prevents an older `output.pdf.json` from being +`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