Preparing (gg)plots for publication: part II

Formatting, multi-panel layouts, and saving plots

ggplot2
dataviz
plotting
tidyverse
Author
Affiliation

Jelmer Poelstra

Published

March 9, 2026



1 Introduction

1.1 What we’ll cover

Today, we’ll continue on the topic of preparing your plots for publication. Specifically, we’ll cover:

  • Saving plots to file – with ggplot’s ggsave() (moved from last weeksince we didn’t get there)

  • Advanced text formatting with ggtext. For example, italicizing individual words in axis titles and legends.

  • Adding any kind of image to a plot, including:

    • An image inside a plot
    • A plot of an image in a multi-panel layout

1.2 Setup

Loading packages

We’ll start by loading the tidyverse, which includes ggplot2, and palmerpenguins, which contains the penguins dataset we’ve been working with:

library(tidyverse)
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.2.0     ✔ readr     2.2.0
✔ forcats   1.0.1     ✔ stringr   1.6.0
✔ ggplot2   4.0.2     ✔ tibble    3.3.1
✔ lubridate 1.9.5     ✔ tidyr     1.3.2
✔ purrr     1.2.1     
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(palmerpenguins)

Attaching package: 'palmerpenguins'

The following objects are masked from 'package:datasets':

    penguins, penguins_raw

Like last week, we will load the patchwork package as well, which provides a convenient syntax for multi-panel layouts:

library(patchwork)

Setting the theme

Like we learned last week, we can set a plotting theme with theme_set(), and customize the theme with theme_update():

theme_set(theme_classic(base_size = 13))
theme_update(
  panel.grid.major.y = element_line(color = "grey70", linewidth = 0.2)
)

A base plot

We’ll also create the same basic plot as last week, so that we have a plot to work with when saving plots:

p <- penguins |>
  drop_na() |> # Get rid of rows with NAs
  ggplot(aes(x = flipper_length_mm, y = body_mass_g)) +
  geom_point(aes(fill = species), shape = 21, size = 2) +
  scale_fill_brewer(palette = "Dark2") +
  scale_y_continuous(labels = scales::label_comma()) +
  labs(x = "Flipper length (mm)", y = "Body mass (g)")

p


2 Saving plots to file

So far, we printed plots to the screen in RStudio but did not save them to file. You may have noticed the “Export” button in the Plots pane — it works, but a more flexible and reproducible approach is the ggsave() function.

2.1 Basic usage

Tell ggsave() about the filename and the plot you want to save:

ggsave(filename = "plot.png", plot = p)
Saving 7 x 5 in image
  • The file type is determined by the extension you provide. Above, we used .png i.e. a PNG file, but there are other options, as we’ll see in a bit.

  • We did not specify the width and height of the figure above, so it reports saving it to a default size of 7 × 5 inches.

By default, ggsave() saves the last plot you produced, and you only need to specify the filename:

ggsave(filename = "plot.png")

This can e.g. be handy because you may not have saved your plot to an object.

In both cases above, the file would be saved to the folder that represents your current “working directory” (directory = folder) in R. But you can save the file to any location on your computer by specifying the path as part of the filename:

# Save to a "figures" subfolder in the current working directory:
ggsave(filename = "figures/plot.png", plot = p)

# Save to a specific folder on your computer:
ggsave(filename = "C:/Users/YourName/Documents/figures/plot.png", plot = p)

To learn more about file paths and how R deals with them, see this Code Club session from last fall.

If the folder you’re trying to save to does not already exist, a prompt like this will appear to create the folder:


2.2 Controlling figure size

ggsave() accepts width and height arguments, with a default unit of inches1:

ggsave(filename = "wide.png", plot = p, width = 6, height = 4)

The figure’s size may not seem to matter much, because you could always resize the figure on the computer, right? In fact, what does an inch even mean in the context of a figure displayed on a screen?

But these dimensions are important when considering the figure’s aspect ratio and the relative text size.

Aspect ratio

The figure’s width relative to the height, somewhat obviously, determines its aspect ratio – and this can be useful to be mindful of and customize when appropriate.

Above, our figure is 50% wider than it is high, and that is sensible because we have a legend on the right-hand side of the plot that takes up horizontal space. But for the sake of practice, let’s save the same plot with a different aspect ratio:

ggsave(filename = "tall.png", plot = p, width = 4, height = 6)

Here is what the resulting figures should look like:

Relative text size

Text and points do not scale with the figure — they stay at their absolute sizes (in points/mm). This means:

  • A plot saved at 3 × 3 will have large-looking text relative to the data
  • A plot saved at 10 × 8 will have small-looking text

This behavior may seem strange but is actually useful: if your text looks too large or too small in the saved file, you can adjust width and height rather than modifying the plotting code. Let’s see this in action, too:

# "Small" figure — text will look relatively large
ggsave(filename = "small.png", plot = p, width = 4.5, height = 3)

# "Large" figure — text will look relatively small
ggsave(filename = "large.png", plot = p, width = 9, height = 6)

Here is what the resulting figures should look like:

2.3 Raster vs. vector formats

When saving a figure, you have two fundamentally different types of format to choose from:

  • Raster formats (PNG, JPEG, TIFF) store the image as a grid of pixels.
    Quality is fixed at save time using the figure’s “resolution” — but zoom in far enough and you’ll see pixelation. Among these formats, TIFF is generally preferred for scientific publications while PNG may also work, while JPEG should typically be avoided.

  • Vector formats (SVG, PDF) store the image as geometric instructions — lines, curves, text.
    They render sharply at any size, and text is selectable and searchable. Among these formats, SVG is generally preferred or scientific publications.

For publication, vector formats are generally preferable when the journal accepts them. Figures will be crisp at any print size, and editors or co-authors can make minor tweaks in a vector editor such as InkScape or Illustrator.

To save to SVG format, we need the svglite package – let’s install that:

install.packages("svglite")
# (We don't need to load it with library() because ggsave() will use it 
#  automatically when we save to SVG)

As mentioned above, the filetype is simply specified in ggsave() using the file extension. Earlier, we saved to PNG, so let’s now practice saving to SVG:

ggsave(filename = "large.svg", plot = p, width = 9, height = 6)
Controlling resolution for raster formats

For raster formats (PNG, JPEG, TIFF), resolution of the figure is controlled with the dpi argument (dots per inch).

ggsave(
  filename = "large_dpi300.png",
  plot = p,
  width = 6,
  height = 4,
  dpi = 300
  )

A dpi of 300 is sufficient in almost any context, though some journals may request a resolution up to 600 dpi or so.


Exercise: Compare formats

Open the SVG file, and the two large PNG files, and compare how they look when you zoom in closely.

PNG (at 300 dpi!):

SVG:


3 Format individual words with ggtext

While you can set the fontface (plain, bold, italic, etc.) of axis titles, legend titles, and so on with theme(), you can’t make individual words in these titles bold or italic with that approach. (The same is true for applying colors.)

ggtext is a package that lets you use Markdown and HTML markup to format text in ggplot2 plots. It is useful for things like bolding, italicizing or coloring individual words in a label. Install it if you don’t have it yet:

install.packages("ggtext")

Then load it:

library(ggtext)

The main function in ggtext is element_markdown(). It works as a drop-in replacement for element_text() in theme() calls, and enables Markdown and HTML parsing for that text element.

Let’s say for the sake of argument that in our penguin scatterplot, we wanted to make parts of the axis titles bold and italic. The Markdown syntax to make text bold is **text** and *text* to make it italic.

Without element_markdown(), the asterisks are printed literally:

p +
  labs(x = "**Flipper length** (*mm*)", y = "**Body mass** (*g*)")

Once we switch to element_markdown(), the formatting is applied:

p +
  labs(x = "**Flipper length** (*mm*)", y = "**Body mass** (*g*)") +
  theme(
    axis.title.x = element_markdown(),
    axis.title.y = element_markdown()
  )

element_markdown() can be used for any text element in theme(): axis titles, axis tick labels, plot titles, captions, and so on.

As another example, say we wanted italic species names in the legend. First, note that if it’s OK that everything becomes italic, we can just do that with theme():

p +
  theme(legend.text = element_text(face = "italic"))

But what if we needed to make only one part of the text italic? We could do that once again with ggtext. First, let’s define the labels we want to use for the legend, with Markdown syntax for italics:

species_labs <- c(
  "Adélie (*P. adeliae*)",
  "Chinstrap (*P. antarcticus*)", 
  "Gentoo (*P. papua*)"
  )

Next, we can use these labels in the legend with scale_fill_brewer(), and then tell theme() to interpret the legend text as Markdown with element_markdown():

p +
  scale_fill_brewer(palette = "Dark2", labels = species_labs) +
  theme(legend.text = element_markdown())
Scale for fill is already present.
Adding another scale for fill, which will replace the existing scale.

You probably won’t need this in a scientific publication, but it can be useful for other, more informal, contexts. For example, you could color species names in a plot title to match the data colors, making the legend redundant.

To color individual words, use an HTML <span> tag with inline CSS:

<span style="color:#1B9E77">some text</span>

Above, #1B9E77 is the hex code for a specific color. Let’s find the three colors that scale_fill_brewer(palette = "Dark2") assigns to the three species:

RColorBrewer::brewer.pal(3, "Dark2")
[1] "#1B9E77" "#D95F02" "#7570B3"

The species factor levels in the penguins data are alphabetical (Adelie, Chinstrap, Gentoo), so those colors are assigned in that order. Now let’s use them in the title:

p +
  labs(title = "<span style='color:#1B9E77'>**Adelie**</span>,
<span style='color:#D95F02'>**Chinstrap**</span>, and
<span style='color:#7570B3'>**Gentoo**</span> penguins") +
  theme(
    plot.title = element_markdown(),
    legend.position = "none"
  )

Because the title now carries the color information, we can choose to remove the legend with legend.position = "none".


Exercise: ….


4 Adding images to plots

To follow along with the code below, download the file 3penguins.png from our GitHub repo:

penguin_img_url <- "https://raw.githubusercontent.com/osu-codeclub/osu-codeclub.github.io/main/posts/S11E06_ggplot_06/3penguins.png"
download.file(url = penguin_img_url, destfile = "3penguins.png")

We’ll also load two additional packages for working with images in R:

library(grid)   # Should always be installed
library(png)    # If you don't have it, install with install.packages("png")

4.1 An image in a multi-panel layout

First, we’ll practice adding an image to a multi-panel layout, so basically treating the image as just another plot in the layout.

Let’s start with the same three-panel layout we had in the previous session:

p_scatter <- penguins |> 
  drop_na() |>
  ggplot(aes(x = bill_length_mm, y = bill_depth_mm)) +
  geom_point() +
  facet_wrap(vars(species))
p_bar <- penguins |> 
  ggplot(aes(x = island)) +
  geom_bar()
p_box <- penguins |> 
  drop_na() |> 
  ggplot(aes(x = sex, y = body_mass_g)) +
  geom_boxplot()

p_scatter / (p_bar | p_box)

First, we’ll read the image in as a graphical object with readPNG() and rasterGrob():

img <- rasterGrob(readPNG("3penguins.png"))

Next, we can add the image to the layout with patchwork’s wrap_elements() function:

# Store the image as a plot element with wrap_elements():
p_img <- wrap_elements(panel = img)

# Add the image to the layout with patchwork syntax:
(p_scatter + p_img) / (p_bar | p_box)

Above, the plot with the image is quite small, and this is because the image is being resized to fit the plot panel (that is, the area inside the axes of a plot). Note that it’s taking up the entire plot panel height, but because the image has a 1:1 aspect ratio, it is not taking up the entire plot panel width. While that makes it appear even smaller, it’s a good thing that the image’s aspect ratio is respected, or the image would look stretched.

Alternatively, we can tell wrap_elements() to use the entire plot area for the image with plot = (above, we used panel =):

p_img_big <- wrap_elements(plot = img)
(p_scatter + p_img_big) / (p_bar | p_box)

A final alternative is to read the image in with the magick package. That’s an easy way to fix the aspect ratio of the image, such that patchwork will automatically take that aspect ratio into account when allocating space for each plot in the layout.

First, install and then load the package:

install.packages("magick")
library(magick)
Linking to ImageMagick 6.9.13.29
Enabled features: cairo, fontconfig, freetype, heic, lcms, pango, raw, rsvg, webp
Disabled features: fftw, ghostscript, x11

Now use magick’s image_read() to read the image in, and image_ggplot() to convert it to a ggplot object:

p_img_fixed <- image_ggplot(image_read("3penguins.png"))

(p_scatter + p_img_fixed) / (p_bar + p_box)

4.2 An image inside a plot

I think this is most easily done with the cowplot package, which has a draw_image() function to add images to ggplots. Install cowplot if you don’t have it already, then load it:

install.packages("cowplot")
library(cowplot)

Attaching package: 'cowplot'
The following object is masked from 'package:patchwork':

    align_plots
The following object is masked from 'package:lubridate':

    stamp

Now, we can add the image to our plot with ggdraw() and draw_image(), where:

  • The x and y arguments specify the position of the image in the plot (between 0 and 1), with x being the left-most position and y being the bottom-most position. These are not the same as the x and y axes of the plot, but rather the position of the image relative to the plot area.

  • The height and width arguments specify the size of the image relative to the plot (between 0 and 1). One thing to note is that the aspect ratio of the image is preserved, so even if you specify a square image like we do below, the actual image may not be square if the original image is not square. While it may seem odd in that case to specify both width and height, when omitting one of these, the function somehow has strange behavior with respect to the x and y coordinates. 🤷‍♂️

ggdraw(p) +
  draw_image("3penguins.png", x = 0.1, y = 0.65, height = 0.35, width = 0.35)

4.3 An example with multiple images

Say that we wanted to add a separate image for each penguin species in an appopriate part of the plot. First, download the following penguin images:

adelie_img_url <- "https://raw.githubusercontent.com/osu-codeclub/osu-codeclub.github.io/main/posts/S11E06_ggplot_06/adelie.png"
chinstrap_img_url <- "https://raw.githubusercontent.com/osu-codeclub/osu-codeclub.github.io/main/posts/S11E06_ggplot_06/chinstrap.png"
gentoo_img_url <- "https://raw.githubusercontent.com/osu-codeclub/osu-codeclub.github.io/main/posts/S11E06_ggplot_06/gentoo.png"

download.file(url = adelie_img_url, destfile = "adelie.png")
download.file(url = chinstrap_img_url, destfile = "chinstrap.png")
download.file(url = gentoo_img_url, destfile = "gentoo.png")

And add them to the plot:

ggdraw(p) +
  draw_image("adelie.png", x = 0.2, y = 0.6, height = 0.15, width = 0.15) +
  draw_image("chinstrap.png", x = 0.5, y = 0.25, height = 0.15, width = 0.15) +
  draw_image("gentoo.png", x = 0.4, y = 0.8, height = 0.15, width = 0.15)

I thought drawing colored boxes around the penguins images could be a useful example. This turned out to be more difficult than expected, so we won’t cover it during the Code Club session. But for your and my future reference, I am including the code here.

First off, I couldn’t get this to work in combination with the draw_image() approach used above to insert the images. Instead, we’ll have to switch to a patchwork-based approach. To insert a single image like we did above, that would look as follows:

# Setting the image width as a variables
width <- 0.35

# Read in the image
raw <- readPNG("3penguins.png")
img <- rasterGrob(raw, width = unit(1, "npc"), height = unit(1, "npc"))

# Obtain the image's aspect ratio and compute the height from that:
# This is necessary because we need to specify the coordinates of all corners
# of the image when using inset_element(), and we want the aspect ratio to be correct.
aspect_ratio <- dim(raw)[1] / dim(raw)[2]
height <- width * aspect_ratio

# Create the plot with the inserted image
# (Setting the plot panel's aspect ratio to 1 seems necessary for the image's
#  aspect ratio to be correctly displayed.)
p +
  theme(aspect.ratio = 1) +
  inset_element(img, right = 0.4, left = 0.4 - width, top = 1, bottom = 1 - height)

That works, but you can hopefully see why I choose to use draw_image() instead for the earlier examples. Now, let’s expand this approach in order to insert individual penguins’ images with colored boxes:

# Set the width and save the desired box colors
width <- 0.15
colors <- RColorBrewer::brewer.pal(n = 8, name = "Dark2")
# Adelie
raw_ade <- readPNG("adelie.png")
img_ade <- rasterGrob(raw_ade, width = unit(1, "npc"), height = unit(1, "npc"))
aspect_ade <- dim(raw_ade)[1] / dim(raw_ade)[2]
ht_ade <- width * aspect_ade
box_ade <- ggplot() +
  theme_void() +
  theme(panel.border = element_rect(fill = NA, color = colors[1], linewidth = 2))

# Chinstrap
raw_chi <- readPNG("chinstrap.png")
img_chi <- rasterGrob(raw_chi, width = unit(1, "npc"), height = unit(1, "npc"))
aspect_chi <- dim(raw_chi)[1] / dim(raw_chi)[2]
ht_chi <- width * aspect_chi
box_chi <- ggplot() +
  theme_void() +
  theme(panel.border = element_rect(fill = NA, color = colors[2], linewidth = 2))

# Gentoo
raw_gen <- readPNG("gentoo.png")
img_gen <- rasterGrob(raw_gen, width = unit(1, "npc"), height = unit(1, "npc"))
aspect_gen <- dim(raw_gen)[1] / dim(raw_gen)[2]
ht_gen <- width * aspect_gen
box_gen <- ggplot() +
  theme_void() +
  theme(panel.border = element_rect(fill = NA, color = colors[3], linewidth = 2))

p +
  theme(aspect.ratio = 1) +
  inset_element(img_ade, right = 0.2, left = 0.2 - width, top = 0.6, bottom = 0.6 - ht_ade) +
  inset_element(box_ade, right = 0.2, left = 0.2 - width, top = 0.6, bottom = 0.6 - ht_ade) +
  inset_element(img_chi, right = 0.7, left = 0.7 - width, top = 0.3, bottom = 0.3 - ht_chi) +
  inset_element(box_chi, right = 0.7, left = 0.7 - width, top = 0.3, bottom = 0.3 - ht_chi) +
  inset_element(img_gen, right = 0.6, left = 0.6 - width, top = 0.9, bottom = 0.9 - ht_gen) +
  inset_element(box_gen, right = 0.6, left = 0.6 - width, top = 0.9, bottom = 0.9 - ht_gen)

Back to top

Footnotes

  1. You can change this with the units argument, other options are: ("cm", "mm", or "px").↩︎