R/save.R

Defines functions absorb_grdevice_args grid.draw.ggplot plot_dev plot_dim parse_dpi check_path ggsave

Documented in ggsave

#' Save a ggplot (or other grid object) with sensible defaults
#'
#' `ggsave()` is a convenient function for saving a plot. It defaults to
#' saving the last plot that you displayed, using the size of the current
#' graphics device. It also guesses the type of graphics device from the
#' extension.
#'
#' Note: Filenames with page numbers can be generated by including a C
#' integer format expression, such as `%03d` (as in the default file name
#' for most R graphics devices, see e.g. [png()]).
#' Thus, `filename = "figure%03d.png"` will produce successive filenames
#' `figure001.png`, `figure002.png`, `figure003.png`, etc. To write a filename
#' containing the `%` sign, use `%%`. For example, `filename = "figure-100%%.png"`
#' will produce the filename `figure-100%.png`.
#'
#' @section Saving images without ggsave():
#'
#' In most cases `ggsave()` is the simplest way to save your plot, but
#' sometimes you may wish to save the plot by writing directly to a
#' graphics device. To do this, you can open a regular R graphics
#' device such as `png()` or `pdf()`, print the plot, and then close
#' the device using `dev.off()`. This technique is illustrated in the
#' examples section.
#'
#' @param filename File name to create on disk.
#' @param plot Plot to save, defaults to last plot displayed.
#' @param device Device to use. Can either be a device function
#'   (e.g. [png]), or one of "eps", "ps", "tex" (pictex),
#'   "pdf", "jpeg", "tiff", "png", "bmp", "svg" or "wmf" (windows only). If
#'   `NULL` (default), the device is guessed based on the `filename` extension.
#' @param path Path of the directory to save plot to: `path` and `filename`
#'   are combined to create the fully qualified file name. Defaults to the
#'   working directory.
#' @param scale Multiplicative scaling factor.
#' @param width,height Plot size in units expressed by the `units` argument.
#'   If not supplied, uses the size of the current graphics device.
#' @param units One of the following units in which the `width` and `height`
#'   arguments are expressed: `"in"`, `"cm"`, `"mm"` or `"px"`.
#' @param dpi Plot resolution. Also accepts a string input: "retina" (320),
#'   "print" (300), or "screen" (72). Only applies when converting pixel units,
#'   as is typical for raster output types.
#' @param limitsize When `TRUE` (the default), `ggsave()` will not
#'   save images larger than 50x50 inches, to prevent the common error of
#'   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`.
#'
#' @seealso
#' The `r link_book("saving section", "themes#sec-saving")`
#' @export
#' @examples
#' \dontrun{
#' ggplot(mtcars, aes(mpg, wt)) +
#'   geom_point()
#'
#' # here, the device is inferred from the filename extension
#' ggsave("mtcars.pdf")
#' ggsave("mtcars.png")
#'
#' # setting dimensions of the plot
#' ggsave("mtcars.pdf", width = 4, height = 4)
#' ggsave("mtcars.pdf", width = 20, height = 20, units = "cm")
#'
#' # passing device-specific arguments to '...'
#' ggsave("mtcars.pdf", colormodel = "cmyk")
#'
#' # delete files with base::unlink()
#' unlink("mtcars.pdf")
#' unlink("mtcars.png")
#'
#' # specify device when saving to a file with unknown extension
#' # (for example a server supplied temporary file)
#' file <- tempfile()
#' ggsave(file, device = "pdf")
#' unlink(file)
#'
#' # save plot to file without using ggsave
#' p <-
#'   ggplot(mtcars, aes(mpg, wt)) +
#'   geom_point()
#' png("mtcars.png")
#' print(p)
#' dev.off()
#'
#' }
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,
                   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(bg)) {
    bg <- calc_element("plot.background", plot_theme(plot))$fill %||% "transparent"
  }
  old_dev <- grDevices::dev.cur()
  dev(filename = filename, width = dim[1], height = dim[2], bg = bg, ...)
  on.exit(utils::capture.output({
    grDevices::dev.off()
    if (old_dev > 1) grDevices::dev.set(old_dev) # restore old device unless null device
  }))
  grid.draw(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".
#'
#' @param dpi Input value from user
#' @return Parsed DPI input value
#' @noRd
parse_dpi <- function(dpi, call = caller_env()) {
  if (is_scalar_character(dpi)) {
    arg_match0(dpi, c("screen", "print", "retina"), error_call = call)

    switch(dpi,
      screen = 72,
      print = 300,
      retina = 320,
    )
  } else if (is_scalar_numeric(dpi)) {
    dpi
  } else {
    stop_input_type(dpi, "a single number or string", call = call)
  }
}

plot_dim <- function(dim = c(NA, NA), scale = 1, units = "in",
                     limitsize = TRUE, dpi = 300, call = caller_env()) {
  units <- arg_match0(units, c("in", "cm", "mm", "px"))
  to_inches <- function(x) x / c(`in` = 1, cm = 2.54, mm = 2.54 * 10, px = dpi)[units]
  from_inches <- function(x) x * c(`in` = 1, cm = 2.54, mm = 2.54 * 10, px = dpi)[units]

  dim <- to_inches(dim) * scale

  if (any(is.na(dim))) {
    if (length(grDevices::dev.list()) == 0) {
      default_dim <- c(7, 7)
    } else {
      default_dim <- grDevices::dev.size() * scale
    }
    dim[is.na(dim)] <- default_dim[is.na(dim)]
    dim_f <- prettyNum(from_inches(dim), digits = 3)

    cli::cli_inform("Saving {dim_f[1]} x {dim_f[2]} {units} image")
  }

  if (limitsize && any(dim >= 50)) {
    units <- switch(
      units,
      "in" = "inches",
      "cm" = "centimeters",
      "mm" = "millimeters",
      "px" = "pixels"
    )
    msg <- paste0(
      "Dimensions exceed 50 inches ({.arg height} and {.arg width} are ",
      "specified in {.emph {units}}"
    )
    if (units == "pixels") {
      msg <- paste0(msg, ").")
    } else {
      msg <- paste0(msg, " not pixels).")
    }
    cli::cli_abort(c(
      msg,
      "i" = "If you're sure you want a plot that big, use {.code limitsize = FALSE}.
    "), call = call)
  }

  dim
}

plot_dev <- function(device, filename = NULL, dpi = 300, call = caller_env()) {
  force(filename)
  force(dpi)

  if (is.function(device)) {
    args <- formals(device)
    call_args <- list()
    if ("file" %in% names(args)) {
      call_args$file <- filename
      call_args["filename"] <- list(NULL)
    }
    if ("res" %in% names(args)) {
      call_args$res <- dpi
    }
    if ("units" %in% names(args)) {
      call_args$units <- 'in'
    }
    dev <- function(...) {
      args <- modify_list(list(...), call_args)
      inject(device(!!!args))
    }
    return(dev)
  }

  eps <- function(filename, ...) {
    grDevices::postscript(file = filename, ..., onefile = FALSE, horizontal = FALSE,
      paper = "special")
  }
  if (requireNamespace('ragg', quietly = TRUE)) {
    png_dev <- absorb_grdevice_args(ragg::agg_png)
    jpeg_dev <- absorb_grdevice_args(ragg::agg_jpeg)
    tiff_dev <- absorb_grdevice_args(ragg::agg_tiff)
  } else {
    png_dev <- grDevices::png
    jpeg_dev <- grDevices::jpeg
    tiff_dev <- grDevices::tiff
  }
  devices <- list(
    eps =  eps,
    ps =   eps,
    tex =  function(filename, ...) grDevices::pictex(file = filename, ...),
    pdf =  function(filename, ..., version = "1.4") grDevices::pdf(file = filename, ..., version = version),
    svg =  function(filename, ...) svglite::svglite(file = filename, ...),
    # win.metafile() doesn't have `bg` arg so we need to absorb it before passing `...`
    emf =  function(..., bg = NULL) grDevices::win.metafile(...),
    wmf =  function(..., bg = NULL) grDevices::win.metafile(...),
    png =  function(...) png_dev(..., res = dpi, units = "in"),
    jpg =  function(...) jpeg_dev(..., res = dpi, units = "in"),
    jpeg = function(...) jpeg_dev(..., res = dpi, units = "in"),
    bmp =  function(...) grDevices::bmp(..., res = dpi, units = "in"),
    tiff = function(...) tiff_dev(..., res = dpi, units = "in"),
    tif  = function(...) tiff_dev(..., res = dpi, units = "in")
  )

  if (is.null(device)) {
    device <- to_lower_ascii(tools::file_ext(filename))
    if (identical(device, "")) {
      cli::cli_abort(c(
        "Can't save to {filename}.",
        i = "Either supply {.arg filename} with a file extension or supply {.arg device}."),
        call = call)
    }
  }

  if (!is.character(device) || length(device) != 1) {
    stop_input_type(device, "a string, function", allow_null = TRUE, call = call)
  }

  dev <- devices[[device]]
  if (is.null(dev)) {
    cli::cli_abort("Unknown graphics device {.val {device}}", call = call)
  }
  dev
}

#' @export
grid.draw.ggplot <- function(x, recording = TRUE) {
  print(x)
}

absorb_grdevice_args <- function(f) {
  function(..., type, antialias) {
    if (!missing(type) || !missing(antialias)) {
      cli::cli_warn("Using ragg device as default. Ignoring {.arg type} and {.arg antialias} arguments")
    }
    f(...)
  }
}
tidyverse/ggplot2 documentation built on May 1, 2024, 1:12 p.m.