diff --git a/NEWS.md b/NEWS.md index 29c5355eb7..6bf14d4615 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,8 @@ # ggplot2 (development version) +* `ggsave()` no longer sometimes creates new directories, which is now + controlled by the new `create.dir` argument (#5489). + * `guide_coloursteps(even.steps = FALSE)` now draws one rectangle per interval instead of many small ones (#5481). diff --git a/R/save.R b/R/save.R index 41df886653..e4f7398155 100644 --- a/R/save.R +++ b/R/save.R @@ -43,6 +43,10 @@ #' specifying dimensions in pixels. #' @param bg Background colour. If `NULL`, uses the `plot.background` fill value #' from the plot theme. +#' @param create.dir Whether to create new directories if a non-existing +#' directory is specified in the `filename` or `path` (`TRUE`) or return an +#' error (`FALSE`, default). If `FALSE` and run in an interactive session, +#' a prompt will appear asking to create a new directory when necessary. #' @param ... Other arguments passed on to the graphics device function, #' as specified by `device`. #' @export @@ -84,27 +88,16 @@ ggsave <- function(filename, plot = last_plot(), device = NULL, path = NULL, scale = 1, width = NA, height = NA, units = c("in", "cm", "mm", "px"), - dpi = 300, limitsize = TRUE, bg = NULL, ...) { - if (length(filename) != 1) { - if (length(filename) == 0) { - cli::cli_abort("{.arg filename} cannot be empty.") - } - len <- length(filename) - filename <- filename[1] - cli::cli_warn(c( - "{.arg filename} must have length 1, not length {len}.", - "!" = "Only the first, {.file {filename}}, will be used." - )) - } + dpi = 300, limitsize = TRUE, bg = NULL, + create.dir = FALSE, + ...) { + filename <- check_path(path, filename, create.dir) dpi <- parse_dpi(dpi) dev <- plot_dev(device, filename, dpi = dpi) dim <- plot_dim(c(width, height), scale = scale, units = units, limitsize = limitsize, dpi = dpi) - if (!is.null(path)) { - filename <- file.path(path, filename) - } if (is_null(bg)) { bg <- calc_element("plot.background", plot_theme(plot))$fill %||% "transparent" } @@ -119,6 +112,56 @@ ggsave <- function(filename, plot = last_plot(), invisible(filename) } +check_path <- function(path, filename, create.dir, + call = caller_env()) { + + if (length(filename) > 1 && is.character(filename)) { + cli::cli_warn(c( + "{.arg filename} must have length 1, not {length(filename)}.", + "!" = "Only the first, {.file {filename[1]}}, will be used." + ), call = call) + filename <- filename[1] + } + check_string(filename, allow_empty = FALSE, call = call) + + check_string(path, allow_empty = FALSE, allow_null = TRUE, call = call) + if (!is.null(path)) { + filename <- file.path(path, filename) + } else { + path <- dirname(filename) + } + + # Happy path: target file is in valid directory + if (dir.exists(path)) { + return(filename) + } + + check_bool(create.dir, call = call) + + # Try to ask user to create a new directory + if (interactive() && !create.dir) { + cli::cli_bullets(c( + "Cannot find directory {.path {path}}.", + "i" = "Would you like to create a new directory?" + )) + create.dir <- utils::menu(c("Yes", "No")) == 1 + } + + # Create new directory + if (create.dir) { + dir.create(path, recursive = TRUE) + if (dir.exists(path)) { + cli::cli_alert_success("Created directory: {.path {path}}.") + return(filename) + } + } + + cli::cli_abort(c( + "Cannot find directory {.path {path}}.", + i = "Please supply an existing directory or use {.code create.dir = TRUE}." + ), call = call) +} + #' Parse a DPI input from the user #' #' Allows handling of special strings when user specifies a DPI like "print". diff --git a/man/ggsave.Rd b/man/ggsave.Rd index cfa68b688b..4a864d16b6 100644 --- a/man/ggsave.Rd +++ b/man/ggsave.Rd @@ -16,6 +16,7 @@ ggsave( dpi = 300, limitsize = TRUE, bg = NULL, + create.dir = FALSE, ... ) } @@ -51,6 +52,11 @@ specifying dimensions in pixels.} \item{bg}{Background colour. If \code{NULL}, uses the \code{plot.background} fill value from the plot theme.} +\item{create.dir}{Whether to create new directories if a non-existing +directory is specified in the \code{filename} or \code{path} (\code{TRUE}) or return an +error (\code{FALSE}, default). If \code{FALSE} and run in an interactive session, +a prompt will appear asking to create a new directory when necessary.} + \item{...}{Other arguments passed on to the graphics device function, as specified by \code{device}.} } diff --git a/tests/testthat/test-ggsave.R b/tests/testthat/test-ggsave.R index 150a8b37a9..4e53dc39d3 100644 --- a/tests/testthat/test-ggsave.R +++ b/tests/testthat/test-ggsave.R @@ -9,6 +9,21 @@ test_that("ggsave creates file", { expect_true(file.exists(path)) }) +test_that("ggsave can create directories", { + dir <- tempdir() + path <- file.path(dir, "foobar", "tmp.pdf") + on.exit(unlink(path)) + + p <- ggplot(mpg, aes(displ, hwy)) + geom_point() + + expect_error(ggsave(path, p)) + expect_false(dir.exists(dirname(path))) + + # 2 messages: 1 for saving and 1 informing about directory creation + expect_message(expect_message(ggsave(path, p, create.dir = TRUE))) + expect_true(dir.exists(dirname(path))) +}) + test_that("ggsave restores previous graphics device", { # When multiple devices are open, dev.off() restores the next one in the list, # not the previously-active one. (#2363) @@ -70,7 +85,7 @@ test_that("ggsave warns about empty or multiple filenames", { expect_error( ggsave(character(), plot), - "`filename` cannot be empty." + "`filename` must be a single string" ) }) @@ -93,7 +108,7 @@ test_that("guesses and informs if dim not specified", { }) test_that("uses 7x7 if no graphics device open", { - expect_equal(plot_dim(), c(7, 7)) + suppressMessages(expect_equal(plot_dim(), c(7, 7))) }) test_that("warned about large plot unless limitsize = FALSE", {