Skip to content

Metadata#19

Open
Polkas wants to merge 12 commits into
mainfrom
metadata
Open

Metadata#19
Polkas wants to merge 12 commits into
mainfrom
metadata

Conversation

@Polkas
Copy link
Copy Markdown
Collaborator

@Polkas Polkas commented May 10, 2026

Add metadata argument to export_to() with JSON sidecar support

Introduces an opt-in metadata argument to export_to() that records set_cell() text values alongside exported files.

What's new

  • export_to() gains a metadata argument ("none" / "sidecar"). Passing "sidecar" writes a <file>.json sidecar next to the output — a flat JSON object for single-page exports, or a JSON array (one entry per page) for multi-page PDFs.
  • A gridify.export.metadata global option allows project-wide defaults (e.g. in .Rprofile).
  • Internal helpers extracted to gridify-utils.R: gridify_metadata(), gridify_to_json(), write_metadata_sidecar(), resolve_export_metadata().
  • jsonlite added as Suggests (not Imports) — only required when metadata = "sidecar" is used; a clear requireNamespace guard provides an install prompt otherwise.

Tests & docs

  • New test files: test_export_to.R, test_gridify_to_json.R.
  • New man pages for all internal helpers.
  • Vignettes (simple_examples, multi_page_examples) and NEWS.md updated.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 10, 2026

Codecov Report

❌ Patch coverage is 97.77778% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.40%. Comparing base (01b7233) to head (2c04b10).

Files with missing lines Patch % Lines
R/gridify-utils.R 96.66% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #19      +/-   ##
==========================================
- Coverage   99.57%   99.40%   -0.18%     
==========================================
  Files           9        9              
  Lines         936     1000      +64     
==========================================
+ Hits          932      994      +62     
- Misses          4        6       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Polkas Polkas marked this pull request as ready for review May 25, 2026 16:33
@Polkas Polkas closed this May 26, 2026
@Polkas Polkas reopened this May 26, 2026
@Polkas
Copy link
Copy Markdown
Collaborator Author

Polkas commented May 28, 2026

Examples to run manually:

if (requireNamespace("devtools", quietly = TRUE)) {
  devtools::load_all(".", quiet = TRUE)
} else {
  library(gridify)
}

if (!requireNamespace("jsonlite", quietly = TRUE)) {
  stop("jsonlite is required for these manual metadata examples.")
}

out_dir <- file.path("sth", "manual_metadata_outputs")
dir.create(out_dir, recursive = TRUE, showWarnings = FALSE)

clean_file <- function(path) {
  unlink(c(path, paste0(path, ".json")), force = TRUE)
}

sidecar <- function(path) paste0(path, ".json")

read_sidecar <- function(path, simplifyVector = FALSE) {
  jsonlite::fromJSON(sidecar(path), simplifyVector = simplifyVector)
}

check_file <- function(path) {
  stopifnot(file.exists(path))
}

check_no_sidecar <- function(path) {
  stopifnot(!file.exists(sidecar(path)))
}

check_sidecar <- function(path) {
  stopifnot(file.exists(sidecar(path)))
}

check_gridify_schema <- function(metadata) {
  stopifnot(identical(metadata$schema, "gridify.sidecar.metadata"))
  stopifnot(identical(metadata$schema_version, "1.0.0"))
}

make_grid <- function(company = "My Company", title = "Manual Metadata PR", watermark = "DRAFT") {
  gridify(grid::rectGrob(), pharma_layout_base()) |>
    set_cell("header_left_1", company) |>
    set_cell("title_1", title) |>
    set_cell("watermark", watermark)
}

make_default_text_grid <- function() {
  gridify(grid::rectGrob(), pharma_layout_base())
}

make_true_empty_grid <- function() {
  gridify(grid::rectGrob(), simple_layout())
}

run_example <- function(label, expr) {
  message("\n", label)
  force(expr)
  message("OK: ", label)
}

old_options <- options(gridify.export.metadata = NULL)
on.exit(options(old_options), add = TRUE)

obj <- make_grid()
obj_special <- make_grid(
  company = "Company / Study X",
  title = "Title with quotes \"x\" and slash \\",
  watermark = "DRAFT\nSecond line"
)
default_text_obj <- make_default_text_grid()
true_empty_obj <- make_true_empty_grid()

run_example("01 default export writes output but no sidecar", {
  path <- file.path(out_dir, "01_default_no_sidecar.pdf")
  clean_file(path)
  export_to(obj, path)
  check_file(path)
  check_no_sidecar(path)
})

run_example("02 metadata sidecar for single PDF", {
  path <- file.path(out_dir, "02_single_pdf_sidecar.pdf")
  clean_file(path)
  export_to(obj, path, metadata = "sidecar")
  check_file(path)
  check_sidecar(path)
  parsed <- read_sidecar(path)
  check_gridify_schema(parsed)
  stopifnot(length(parsed$pages) == 1)
  stopifnot(identical(parsed$pages[[1]]$cells$header_left_1, "My Company"))
  stopifnot(identical(parsed$pages[[1]]$cells$title_1, "Manual Metadata PR"))
})

run_example("03 metadata sidecar for PNG", {
  path <- file.path(out_dir, "03_png_sidecar.png")
  clean_file(path)
  export_to(obj, path, metadata = "sidecar")
  check_file(path)
  check_sidecar(path)
  parsed <- read_sidecar(path)
  check_gridify_schema(parsed)
  stopifnot(identical(parsed$pages[[1]]$cells$watermark, "DRAFT"))
})

run_example("04 metadata sidecar preserves special characters", {
  path <- file.path(out_dir, "04_special_chars.pdf")
  clean_file(path)
  export_to(obj_special, path, metadata = "sidecar")
  check_sidecar(path)
  parsed <- read_sidecar(path)
  check_gridify_schema(parsed)
  stopifnot(identical(parsed$pages[[1]]$cells$title_1, "Title with quotes \"x\" and slash \\"))
  stopifnot(identical(parsed$pages[[1]]$cells$watermark, "DRAFT\nSecond line"))
})

run_example("05 metadata = 's' abbreviation writes sidecar", {
  path <- file.path(out_dir, "05_abbrev_sidecar.pdf")
  clean_file(path)
  export_to(obj, path, metadata = "s")
  check_file(path)
  check_sidecar(path)
})

run_example("06 global option writes sidecar by default", {
  path <- file.path(out_dir, "06_global_option.pdf")
  clean_file(path)
  old <- options(gridify.export.metadata = "sidecar")
  on.exit(options(old), add = TRUE)
  export_to(obj, path)
  check_file(path)
  check_sidecar(path)
})

run_example("07 explicit metadata = 'none' overrides global option and removes stale sidecar", {
  path <- file.path(out_dir, "07_none_overrides_option.pdf")
  clean_file(path)
  export_to(obj, path, metadata = "sidecar")
  check_sidecar(path)
  old <- options(gridify.export.metadata = "sidecar")
  on.exit(options(old), add = TRUE)
  export_to(obj, path, metadata = "none")
  check_file(path)
  check_no_sidecar(path)
})

run_example("08 re-export same path without metadata removes stale sidecar", {
  path <- file.path(out_dir, "08_reexport_none_removes.pdf")
  clean_file(path)
  export_to(obj, path, metadata = "sidecar")
  check_sidecar(path)
  export_to(obj, path, metadata = "none")
  check_file(path)
  check_no_sidecar(path)
})

run_example("09 layout default text writes sidecar without set_cell values", {
  path <- file.path(out_dir, "09_default_layout_text.pdf")
  clean_file(path)
  export_to(default_text_obj, path, metadata = "sidecar")
  check_file(path)
  check_sidecar(path)
  parsed <- read_sidecar(path)
  check_gridify_schema(parsed)
  stopifnot(identical(parsed$pages[[1]]$cells, list(header_right_1 = "CONFIDENTIAL")))
})

run_example("09b true empty object writes no sidecar", {
  path <- file.path(out_dir, "09b_true_empty_no_sidecar.pdf")
  clean_file(path)
  export_to(true_empty_obj, path, metadata = "sidecar")
  check_file(path)
  check_no_sidecar(path)
})

run_example("09c true empty object filled with set_cell writes sidecar", {
  path <- file.path(out_dir, "09c_true_empty_then_filled.pdf")
  clean_file(path)
  filled_obj <- true_empty_obj |>
    set_cell("title", "Filled after empty")
  export_to(filled_obj, path, metadata = "sidecar")
  check_file(path)
  check_sidecar(path)
  parsed <- read_sidecar(path)
  check_gridify_schema(parsed)
  stopifnot(identical(parsed$pages[[1]]$cells, list(title = "Filled after empty")))
})

run_example("09d re-run filled output with true empty object removes sidecar", {
  path <- file.path(out_dir, "09d_filled_then_true_empty_removes.pdf")
  clean_file(path)
  filled_obj <- true_empty_obj |>
    set_cell("title", "Temporary metadata")
  export_to(filled_obj, path, metadata = "sidecar")
  check_file(path)
  check_sidecar(path)
  export_to(true_empty_obj, path, metadata = "sidecar")
  check_file(path)
  check_no_sidecar(path)
})

run_example("10 re-export with changed metadata overwrites old sidecar", {
  path <- file.path(out_dir, "10_overwrite_sidecar.pdf")
  clean_file(path)
  export_to(make_grid(company = "Company A"), path, metadata = "sidecar")
  stopifnot(identical(read_sidecar(path)$pages[[1]]$cells$header_left_1, "Company A"))
  export_to(make_grid(company = "Company B"), path, metadata = "sidecar")
  stopifnot(identical(read_sidecar(path)$pages[[1]]$cells$header_left_1, "Company B"))
})

run_example("11 multi-page PDF writes pages schema with one entry per page", {
  path <- file.path(out_dir, "11_multipage_pdf.pdf")
  clean_file(path)
  export_to(
    list(make_grid(title = "Page 1"), make_grid(title = "Page 2")),
    path,
    metadata = "sidecar"
  )
  check_file(path)
  check_sidecar(path)
  parsed <- read_sidecar(path)
  check_gridify_schema(parsed)
  stopifnot(length(parsed$pages) == 2)
  stopifnot(identical(parsed$pages[[1]]$cells$title_1, "Page 1"))
  stopifnot(identical(parsed$pages[[2]]$cells$title_1, "Page 2"))
})

run_example("12 multi-page PDF with mixed empty and populated pages still writes sidecar", {
  path <- file.path(out_dir, "12_mixed_multipage_pdf.pdf")
  clean_file(path)
  export_to(list(true_empty_obj, make_grid(title = "Only page with metadata")), path, metadata = "sidecar")
  check_sidecar(path)
  parsed <- read_sidecar(path)
  check_gridify_schema(parsed)
  stopifnot(length(parsed$pages) == 2)
  stopifnot(length(parsed$pages[[1]]$cells) == 0)
  stopifnot(identical(parsed$pages[[2]]$cells$title_1, "Only page with metadata"))
})

run_example("13 multiple separate files write one sidecar per file", {
  paths <- file.path(out_dir, sprintf("13_separate_%s.png", 1:2))
  lapply(paths, clean_file)
  export_to(
    list(make_grid(title = "Separate 1"), make_grid(title = "Separate 2")),
    paths,
    metadata = "sidecar"
  )
  lapply(paths, check_file)
  lapply(paths, check_sidecar)
  stopifnot(identical(read_sidecar(paths[[1]])$pages[[1]]$cells$title_1, "Separate 1"))
  stopifnot(identical(read_sidecar(paths[[2]])$pages[[1]]$cells$title_1, "Separate 2"))
})

run_example("14 multiple separate files remove stale sidecars when metadata is truly empty", {
  paths <- file.path(out_dir, sprintf("14_separate_empty_%s.pdf", 1:2))
  lapply(paths, clean_file)
  export_to(list(make_grid(title = "Has metadata"), make_grid(title = "Also metadata")), paths, metadata = "sidecar")
  lapply(paths, check_sidecar)
  export_to(list(true_empty_obj, true_empty_obj), paths, metadata = "sidecar")
  lapply(paths, check_file)
  lapply(paths, check_no_sidecar)
})

run_example("15 invalid metadata value errors", {
  path <- file.path(out_dir, "15_invalid_metadata.pdf")
  clean_file(path)
  err <- tryCatch(
    {
      export_to(obj, path, metadata = "yes")
      NULL
    },
    error = identity
  )
  stopifnot(inherits(err, "error"))
  stopifnot(grepl("should be one of", conditionMessage(err), fixed = TRUE))
})

run_example("16 TIFF sidecar when TIFF device is available", {
  path <- file.path(out_dir, "16_tiff_sidecar.tiff")
  clean_file(path)
  err <- tryCatch(
    {
      export_to(obj, path, metadata = "sidecar")
      NULL
    },
    error = identity
  )
  if (inherits(err, "error")) {
    message("Skipped TIFF check because this R build/device failed: ", conditionMessage(err))
  } else {
    check_file(path)
    check_sidecar(path)
    parsed <- read_sidecar(path)
    check_gridify_schema(parsed)
    stopifnot(length(parsed$pages) == 1)
  }
})

run_example("17 multi-page PDF stale sidecar is removed when later pages are truly empty", {
  path <- file.path(out_dir, "17_multipage_empty_removes.pdf")
  clean_file(path)
  export_to(list(make_grid(title = "First"), make_grid(title = "Second")), path, metadata = "sidecar")
  check_sidecar(path)
  export_to(list(true_empty_obj, true_empty_obj), path, metadata = "sidecar")
  check_file(path)
  check_no_sidecar(path)
})

run_example("18 multi-page PDF overwrites page count and page metadata", {
  path <- file.path(out_dir, "18_multipage_overwrite.pdf")
  clean_file(path)
  export_to(list(make_grid(title = "Old 1"), make_grid(title = "Old 2")), path, metadata = "sidecar")
  stopifnot(length(read_sidecar(path)$pages) == 2)
  export_to(list(make_grid(title = "New only page")), path, metadata = "sidecar")
  parsed <- read_sidecar(path)
  check_gridify_schema(parsed)
  stopifnot(length(parsed$pages) == 1)
  stopifnot(identical(parsed$pages[[1]]$cells$title_1, "New only page"))
})

run_example("19 multiple separate PDF files write one-page sidecars with global option", {
  paths <- file.path(out_dir, sprintf("19_global_separate_%s.pdf", 1:3))
  lapply(paths, clean_file)
  old <- options(gridify.export.metadata = "sidecar")
  on.exit(options(old), add = TRUE)
  export_to(
    list(make_grid(title = "Global 1"), make_grid(title = "Global 2"), make_grid(title = "Global 3")),
    paths
  )
  lapply(paths, check_file)
  lapply(paths, check_sidecar)
  parsed <- lapply(paths, read_sidecar)
  lapply(parsed, check_gridify_schema)
  stopifnot(all(vapply(parsed, function(x) length(x$pages) == 1, logical(1))))
  stopifnot(identical(parsed[[3]]$pages[[1]]$cells$title_1, "Global 3"))
})

run_example("20 multiple separate files handle mixed empty and populated metadata", {
  paths <- file.path(out_dir, sprintf("20_mixed_separate_%s.png", 1:3))
  lapply(paths, clean_file)
  export_to(
    list(make_grid(title = "Old 1"), make_grid(title = "Old 2"), make_grid(title = "Old 3")),
    paths,
    metadata = "sidecar"
  )
  lapply(paths, check_sidecar)
  export_to(
    list(default_text_obj, make_grid(title = "Still has metadata"), default_text_obj),
    paths,
    metadata = "sidecar"
  )
  check_sidecar(paths[[1]])
  check_sidecar(paths[[2]])
  check_sidecar(paths[[3]])
  check_gridify_schema(read_sidecar(paths[[1]]))
  check_gridify_schema(read_sidecar(paths[[2]]))
  check_gridify_schema(read_sidecar(paths[[3]]))
  stopifnot(identical(read_sidecar(paths[[1]])$pages[[1]]$cells, list(header_right_1 = "CONFIDENTIAL")))
  stopifnot(identical(read_sidecar(paths[[2]])$pages[[1]]$cells$title_1, "Still has metadata"))
  stopifnot(identical(read_sidecar(paths[[3]])$pages[[1]]$cells, list(header_right_1 = "CONFIDENTIAL")))
})

message("\nManual metadata examples completed. Outputs are in: ", normalizePath(out_dir))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants