I found myself repeatedly writing similar code to generate pubplication ready plots that include LaTeX annotations for my papers and teaching materials. The tikzDevice R package provides the foundation for combining R plots with LaTeX. I use the magick library to convert the compiled PDF file to the desired output format. To streamline this workflow, I wrote a utility function that handles the entire pipeline.

create_latex_plot <- function(
  plot_expr,           # plot object / function call
  out_name,            # out files name
  out_format = "png",  # out format
  out_dir = ".",       # out directory
  width = 9,           # plot width
  height = 6,          # plot height
  cleanup = T          # remove intermediate files
) {
  
  # libraries
  if (!requireNamespace("tikzDevice", quietly = TRUE)) 
    stop("Please install the 'tikzDevice' package.")
  if (!requireNamespace("magick", quietly = TRUE)) 
    stop("Please install the 'magick' package.")
  
  library(tikzDevice)
  library(magick)
  
  # tikzDevice options
  options(tikzLatexPackages = c(
    "\\usepackage{tikz}",
    "\\usepackage[active,tightpage]{preview}",
    "\\PreviewEnvironment{pgfpicture}",
    "\\setlength\\PreviewBorder{0pt}",
    "\\usepackage{amsmath, amssymb, amsthm, amstext}",
    "\\usepackage{bm}"
  ))
  
  # file paths
  tex_file <- file.path(out_dir, paste0(out_name, ".tex"))
  pdf_file <- file.path(out_dir, paste0(out_name, ".pdf"))
  out_file <- file.path(out_dir, paste0(out_name,  ".", out_format))
  
  # tikz file
  tikz(tex_file, standAlone = TRUE, width = width, height = height)
  eval(plot_expr)
  dev.off()
  
  # Compile to PDF
  system(
    paste(
      "cd", out_dir, 
      "; lualatex -output-directory .", 
      shQuote(basename(tex_file)) 
    )
  )
  
  # Convert the PDF to PNG
  image_write(
    image_convert(
      image_read_pdf(pdf_file),
      format = out_format
    ),
    path = out_file,
    format = out_format
  )
  
  message("Output file created at: ", out_file)

  if(cleanup) {
    system("rm *.aux; rm *.log; rm *.tex; rm *.pdf")
    message("Removed intermediate files.")
  }

}

The magick package is doing the PDF to X conversion internally using ImageMagick, which provides a cross-platform solution that doesn’t depend on Ghostscript being installed.

The above version relies on Unix-style shell commands for changing directories and cleaning up temporary files. While these commands work perfectly on macOS and Linux, they would fail on Windows systems which use different command syntax and path separators: Additionally, Windows handles program execution differently, so the way we call (Lua)LaTeX needs some adjustment.1

Below is cross-platform version of the function that should address these points (untested!).

Code
create_latex_plot <- function(
  plot_expr,           # plot object / function call
  out_name,            # out files name
  out_format = "png",  # out format
  out_dir = ".",       # out directory
  width = 9,           # plot width
  height = 6,          # plot height
  cleanup = T          # remove intermediate files
) {
  
  # libraries
  if (!requireNamespace("tikzDevice", quietly = TRUE)) 
    stop("Please install the 'tikzDevice' package.")
  if (!requireNamespace("magick", quietly = TRUE)) 
    stop("Please install the 'magick' package.")
  
  library(tikzDevice)
  library(magick)
  
  # tikzDevice options
  options(tikzLatexPackages = c(
    "\\usepackage{tikz}",
    "\\usepackage[active,tightpage]{preview}",
    "\\PreviewEnvironment{pgfpicture}",
    "\\setlength\\PreviewBorder{0pt}",
    "\\usepackage{amsmath, amssymb, amsthm, amstext}",
    "\\usepackage{bm}"
  ))
  
  # file paths
  tex_file <- file.path(out_dir, paste0(out_name, ".tex"))
  pdf_file <- file.path(out_dir, paste0(out_name, ".pdf"))
  out_file <- file.path(out_dir, paste0(out_name,  ".", out_format))
  
  # tikz file
  tikz(tex_file, standAlone = TRUE, width = width, height = height)
  eval(plot_expr)
  dev.off()
  
  # current working directory
  original_dir <- getwd()
  
  # change directory
  setwd(out_dir)
  
  # system2() for cross-platform
  system2(
    "lualatex",
    args = c(
      "-interaction=nonstopmode",
      "-output-directory=.",
      basename(tex_file)
    ),
    stdout = NULL
  )
  
  # back to original directory
  setwd(original_dir)
  
  # PDF to output conversion
  image_write(
    image_convert(
      image_read_pdf(pdf_file),
      format = out_format
    ),
    path = out_file,
    format = out_format
  )
  
  message("Output file created at: ", out_file)

  # cross-platform cleanup 
  if(cleanup) {
    temp_files <- list.files(
      path = out_dir,
      pattern = paste0(out_name, "\\.(aux|log|tex|pdf)$"),
      full.names = TRUE
    )
    file.remove(temp_files)
    message("Removed intermediate files.")
  }
}

Example: Density Ridges

To demonstrate the utility of this function, let’s create a ridge plot showing Gaussian density functions with their corresponding mathematical formulas as $\LaTeX$ labels.

We first generate some example data.

library(tibble)

# example data
set.seed(123)

data <- tibble(
  value = c(
    rnorm(500, mean = 3, sd = 1), 
    rnorm(500, mean = 5, sd = 1.2), 
    rnorm(500, mean = 7, sd = 1.5)
  ),
  group = rep(c("Group A", "Group B", "Group C"), each = 500)
)

The plot should display kernel estimates of the corresponding Gaussian densities along with $\LaTeX$ formulas. For this we define the formulas as strings and combine these in a tibble with positions and group membership indicators.2

# formulas for each group
formulas <- c(
  "$f(x) = \\frac{1}{\\sqrt{2\\pi}} e^{-\\frac{(x - 3)^2}{2}}$",
  "$f(x) = \\frac{1}{1.2\\sqrt{2\\pi}} e^{-\\frac{(x - 5)^2}{2 \\cdot 1.2^2}}$",
  "$f(x) = \\frac{1}{1.5\\sqrt{2\\pi}} e^{-\\frac{(x - 7)^2}{2 \\cdot 1.5^2}}$"
)

# positions for annotations
annotations <- tibble(
  group = c("Group A", "Group B", "Group C"),
  x = c(6.5, 9, 11),
  y = c(1.6, 2.6, 3.6), 
  formula = formulas
)

We use ggridges::geom_density_ridges in a ggplot2 call to produce a ridgeline plot of the density estimates and pass annotations as data input to ggplot2::geom_text().

# libraries
library(ggplot2)
library(ggridges)

# create the ridge plot
ridge_plot <- ggplot(
  data = data, 
  mapping = aes(x = value, y = group, fill = group)
  ) +
  geom_density_ridges(scale = 1.5, alpha = 0.8) +
  theme_minimal(base_size = 16) +
  scale_fill_brewer(palette = "Set2") +
  labs(
    title = "Density Ridge Plot with Inline \\LaTeX",
    x = "Value",
    y = "Group"
  ) +
  geom_text(
    data = annotations,
    aes(x = x, y = y, label = formula),
    inherit.aes = FALSE,
    size = 5,
    color = "black"
  ) +
  theme(
    legend.position = "none",
    plot.title = element_text(hjust = 0.5),
    plot.subtitle = element_text(hjust = 0.5)
  )

ridge_plot
Picking joint bandwidth of 0.314

Figure 1: ridge_plot in R’s plotting device

Note that ggplot2 objects are plot specifications that only get rendered when printed.3 However, calling ridge_plot in R’s plotting device does involve any further typesetting and so we see the bare $\LaTeX$ commands of the annotation layer here.4

This is where create_latex_plot() works its magic. While printing happens automatically in the R console, the tikzDevice needs an explicit print command. We wrap this command in expression() to create a set of instructions that can be executed at the right moment inside the tikzDevice context, rather than immediately. This ensures the plot renders properly with $\LaTeX$ support. For the output file format, I chose png:

# render 'ridge_plot' using create_latex_plot()
create_latex_plot(
    plot_expr = expression(print(ridge_plot)),
    out_name = "density_ridges",
    out_format = "png",
    out_dir = ".",
    width = 8,
    height = 6
)
Linking to ImageMagick 6.9.12.93
Enabled features: cairo, fontconfig, freetype, heic, lcms, pango, raw, rsvg, webp
Disabled features: fftw, ghostscript, x11

Picking joint bandwidth of 0.314

Output file created at: ./density_ridges.png

Removed intermediate files.

And here’s the result:5

Figure 2: Density ridges with LaTeX formulas

That’s it for now 🙂.


  1. I guess you could use another $\LaTeX$ compiler, but I did not test this. I had some issues with pdflatex. ↩︎

  2. Note that we need to double the backslash \\ in R strings since \ is an escape character used to denote special characters like newlines (\n) or tabs (\t). When the string is processed by LaTeX, a double backslash becomes a single backslash. ↩︎

  3. Calling the object ridge_plot is equivalent to print(ridge_plot)↩︎

  4. Fomula positions are misleading here: The lengthy expressions are centered at the positions defined in the annotations tibble. Rendered $\LaTeX$ formulas often take less space, so better look at the final outcome before adjusting. ↩︎

  5. To include the output in an Rmarkdown file or in a quarto document as I do here, you need to embedd the generated file, for example using ![Density ridges with LaTeX formulas](density_ridges.png)↩︎