Custom Fonts in R Packages: A Practical Guide

How to bundle fonts with ggplot2 themes, pass CRAN checks, and avoid the extrafont trap

r
ggplot2
packages
fonts
Author

Vinicius Oike Reginatto

Published

June 2, 2026

The problem

If you have ever shipped a ggplot2 theme with a custom font, you have probably seen a message similar to this one during R CMD check.

font family 'Poppins' not found in PostScript font database
Error in grid.Call.graphics(C_text, ...) : invalid font type

The font is installed. It works in RStudio. It renders perfectly when you call ragg::agg_png(). Yet CRAN rejects your package.

This post explains why that happens, surveys how existing packages handle it, and provides a step-by-step workflow that passes CRAN checks while still delivering the “it just works” experience for users with the right setup.

R has three font systems that don’t talk to each other

The root cause of every font issue in R is that the language has three independent font resolution layers, and a font visible to one layer is invisible to the others.

Layer 1 – The PostScript/PDF font database

The pdf() and postscript() devices maintain their own internal font registry. It ships with about 14 standard families.

names(pdfFonts())
#> "serif" "sans" "mono" "AvantGarde" "Bookman" "Courier"
#> "Helvetica" "Helvetica-Narrow" "NewCenturySchoolbook" "Palatino" ...

The "sans" alias maps to Helvetica, "serif" to Times, "mono" to Courier. These are the only font families guaranteed to work under pdf(), which is the device R CMD check uses to run your examples. In other words, the safest way to guarantee your custom ggplot2 will work is to work with these fonts.

This layer knows nothing about system-installed fonts or fonts registered with systemfonts.

Layer 2 – System fonts (Cairo, Quartz, GDI)

Cairo-based devices (cairo_pdf(), png(type = "cairo")), the macOS Quartz device, and Windows GDI resolve fonts through the operating system’s native font stack (fontconfig on Linux, Core Text on macOS, the Windows font registry). These devices can use any font installed on the system but cannot see fonts registered with systemfonts::register_font().

Layer 3 – The systemfonts registry

systemfonts maintains its own in-process font registry (a C++ map keyed by family name). Fonts are added via register_font(). Only devices that link to systemfonts at the C level can see these fonts.

Device Package Sees registered fonts?
agg_png(), agg_jpeg(), etc. ragg Yes
svglite() svglite Yes
pdf(), postscript() grDevices No
cairo_pdf(), png() grDevices No

This separation is why a font can render perfectly in an interactive RStudio session (using ragg) but fail during R CMD check (which uses pdf()).

ggplot(...) + theme(text = element_text(family = "Poppins"))

pdf() device?       --> pdfFonts() lookup --> not found --> WARNING
ragg::agg_png()?    --> systemfonts registry --> found --> renders correctly
cairo_pdf()?        --> OS font stack --> only if system-installed

The strategies (and which one to pick)

Before choosing any strategy it’s important to think: does my package need a specific branded or custom font? Making custom fonts work out of the box makes for a better user experience but delegating font management to the end user is also a viable solution. The ggthemes package, for instance, could bundle specific fonts for the theme_economist or theme_excel functions to work even better but it instead opts for a simpler (and safer) solution: it assumes the user doesn’t have the necessary font and defaults to a font that all versions of R can execute.

extrafont – the legacy approach

The extrafont package converts TrueType fonts to PostScript Type 1 format using the abandoned ttf2pt1 binary. It is the only approach that natively supports pdf() and postscript(), but the price is high.

It should not be used in packages. Here is why.

Warningextrafont was archived on CRAN in September 2025

Its dependency Rttf2pt1 was archived first, triggering a cascade. tvthemes was archived the same day and has not returned. hrbrthemes was archived and only came back seven months later by gutting all font registration code. Both extrafont and Rttf2pt1 were restored about two weeks later under a new maintainer, but the damage to downstream packages was done. Any package that hard-depends on extrafont is one archival event away from removal.

Beyond the archival risk, extrafont has structural problems.

  • font_import() crashes on modern fonts. The ttf2pt1 binary is abandonware. It processes fonts sequentially with system2() calls, taking 5–15 minutes on systems with hundreds of fonts, and crashing mid-way on fonts it cannot parse. Multiple issues (#74, #76, #88) report font_import() skipping all fonts with “No FontName. Skipping.”
  • Database corruption. Font metadata lives in a CSV file inside the installed extrafontdb package directory. No file locking, no transactions, no integrity checks. Concurrent sessions can corrupt it.
  • Writes to installed package directories. This fails in Docker, Nix, enterprise lockdowns, and cached CI runners.
  • Session state burden. loadfonts() must run every R session, and package load time scales linearly with the number of imported fonts.

Thomas Lin Pedersen, maintainer of ggplot2 and author of systemfonts, has positioned systemfonts + ragg as the modern replacement in the Tidyverse blog post “Fonts in R” (May 2025), describing extrafont as a historical approach that “sought to mainly improve the situation for the pdf() device.”

showtext – the universal approach

showtext hijacks the active device’s text rendering at the C level, replacing it with FreeType-based path rendering. It works with any device, which sounds ideal.

The main drawback is that text is not selectable or searchable in PDFs. Glyph outlines replace actual text content. It also conflicts with ggtext, has DPI mismatch issues, and installs global hooks with no scoping mechanism. For interactive use and quick scripts it works well, but for packages it adds fragility without enough benefit.

Initial versions of benviplot, one of my own branded ggplot2 packages, used showtext and sysfonts::font_add_google() to access the Poppins font. While this worked, it meant that the package always needed internet access to work and had a rather sluggish loading time, since the Poppins font had to be downloaded in every new R session. Finally, as mentioned before, calling showtext_auto() in the background, in order for the Poppins font to work, meant changing the DPI of output plots silently, which caused friction when exporting the plots.

How other packages handle fonts

Before building our approach, we surveyed the landscape. As mentioned before, not delivering custom fonts is always a viable option and ggthemes is a solid example of a widely used package that does so.

All other packages surveyed had the intent of delivering custom fonts and struggled with CRAN and later with the archival of extrafont.

Package Strategy Fonts bundled? Fallback CRAN examples
ggthemes None No Defaults to "sans" Run freely
thematic systemfonts or showtext Downloads on demand font_spec("") = don’t alter Font ops in \dontrun{}
hrbrthemes None (gutted post-archival) Yes, but never registered None – hardcoded names All in \dontrun{}
firatheme extrafont Yes None Never submitted to CRAN
tvthemes extrafont Yes NULL Archived (Sep 2025)

ggthemes takes the safest approach – it defaults to generic families and never touches font registration. The themes do not look like their real-world counterparts (e.g. theme_economist doesn’t have a font similar to The Economist) out of the box, but there is zero CRAN risk.

hrbrthemes went through the most dramatic transformation. The v0.9.3 release removed all font dependencies entirely. Every single example is wrapped in \dontrun{}, meaning CRAN never runs any of them. The fonts are bundled but serve only as a distribution mechanism for users to install manually.

tvthemes and firatheme demonstrate the extrafont anti-pattern. Both hard-imported extrafont. tvthemes was archived alongside it. firatheme never made it to CRAN.

None of these packages achieve what we wanted: auto-detection that works out of the box for ragg users, with examples that actually run on CRAN.

A step-by-step workflow

This is the approach I implemented in benviplot. It bundles the Poppins font, auto-detects it for ragg users, falls back to "sans" otherwise, and passes R CMD check --as-cran with all examples running.

While wrapping all examples with dontrun is a viable strategy, it’s not considered a good practice and there are better workarounds. Packages like googledrive, gargle, and many others have examples that are “doomed” to fail CRAN checks. Even so, each one of them managed better solution then dontrun1.

1. Bundle the font files in inst/fonts/

This is the easiest way to make your customs fonts available for users. If you plan on bundling multiple fonts, I recommend inserting only the weights that are actually used, since these can take up valuable space. systemfonts::register_font() supports exactly four faces: plain, bold, italic, bolditalic. Each weight adds 100–300KB.

inst/
  fonts/
    Poppins-Regular.ttf
    Poppins-Bold.ttf
    Poppins-Italic.ttf
    Poppins-BoldItalic.ttf

It’s important to note that you should include some form of copyright or license text file alongside your fonts to avoid any complication.

2. Put systemfonts and ragg in Suggests

This isn’t a “hard” rule, but as a general point you package should always try to be minimal with dependencies. The package works without them (using "sans"), and works better with them.

# DESCRIPTION
Suggests:
    ragg,
    systemfonts,
    testthat (>= 3.0.0)

3. Register in .onLoad()

Use .onLoad(), not .onAttach(). The .onAttach() hook only fires on library(), not when another package imports yours or during devtools::load_all().

Always wrap register_font() in tryCatch() – it errors if a font with the same name is already system-installed.

# R/zzz.R
.onLoad <- function(libname, pkgname) {
  if (requireNamespace("systemfonts", quietly = TRUE)) {
    register_bundled_poppins(pkgname)
  }
}

register_bundled_poppins <- function(pkgname) {
  font_dir <- system.file("fonts", package = pkgname)
  if (!nzchar(font_dir)) {
    return(invisible(NULL))
  }

  f <- function(name) file.path(font_dir, name)

  tryCatch(
    systemfonts::register_font(
      name = "Poppins",
      plain = f("Poppins-Regular.ttf"),
      bold = f("Poppins-Bold.ttf"),
      italic = f("Poppins-Italic.ttf"),
      bolditalic = f("Poppins-BoldItalic.ttf")
    ),
    error = function(e) NULL
  )
  invisible(NULL)
}

4. Gate auto-detection on ragg

A font in the systemfonts registry is useless if the active device cannot read it. Since you cannot know the device at theme-creation time, gate on whether ragg is installed as a proxy.

# R/zzz.R
poppins_is_registered <- function() {
  tryCatch(
    {
      if (!requireNamespace("systemfonts", quietly = TRUE)) {
        return(FALSE)
      }

      registry <- systemfonts::registry_fonts()
      has_registered <- nrow(registry) > 0 &&
        any(grepl("Poppins", registry$family, ignore.case = TRUE))

      has_registered && requireNamespace("ragg", quietly = TRUE)
    },
    error = function(e) FALSE
  )
}
ImportantWhy checking ragg is necessary

We initially tried three weaker checks, all of which failed.

  1. Registry-only checksystemfonts::registry_fonts() confirms the font is registered, but pdf() ignores it. Failed on win-builder.
  2. System fonts checksystemfonts::system_fonts() uses CoreText on macOS, which is not the same as the PostScript font database. Failed locally with devtools::check().
  3. ragg-available check – ragg being installed does not mean it is the active device. R CMD check uses pdf() regardless. Failed with --run-donttest.

The ragg gate works because it correctly reports FALSE on CRAN (where ragg is typically not installed) while returning TRUE for users who have the right setup for interactive use.

5. Default to “sans” with an override

# R/theme_benvi.R
default_font_family <- function() {
  if (poppins_is_registered()) "Poppins" else "sans"
}

theme_benvi <- function(
  base_family = getOption("theme_benvi.font_family", default_font_family()),
  base_size = 10,
  ...
) {
  # theme implementation
}

Users who want to force a specific font globally can set options(theme_benvi.font_family = "Poppins") in their .Rprofile. Users who need PDF output can set options(theme_benvi.font_family = "sans").

6. Write CRAN-safe examples

This is where most packages get it wrong. Three patterns work.

Pattern A – For functions where you control the base_family argument, pass "sans" explicitly.

#' @examples
#' library(ggplot2)
#' ggplot(mtcars, aes(wt, mpg)) +
#'   geom_point() +
#'   theme_benvi(base_family = "sans")

Pattern B – For wrapper functions that call theme_benvi() internally (and therefore do not expose base_family), use \dontshow{} to override the option.

#' @examples
#' \dontshow{.op <- options(theme_benvi.font_family = "sans")}
#' plot_line(data = sao_paulo, x = date, y = index)
#' \dontshow{options(.op)}

The \dontshow{} block runs during R CMD check but does not appear on the help page. Users see clean examples; CRAN sees safe ones.

Pattern C – For font-specific examples, use \dontrun{}.

#' \dontrun{
#' # With Poppins (requires systemfonts + ragg)
#' ggplot(mtcars, aes(wt, mpg)) +
#'   geom_point() +
#'   theme_benvi(base_family = "Poppins")
#' }
Caution\donttest{} is not enough

Since R 4.0, CRAN sets _R_CHECK_DONTTEST_EXAMPLES_ to TRUE during submission checks. Code inside \donttest{} IS executed. Only \dontrun{} reliably prevents execution.

7. Provide a diagnostic function

Give users a way to check their setup. This saves significant debugging time.

font_status()
#> -- benviplot Font Status -------
#> v Poppins font: registered (bundled)
#> v ragg package: available
#> v theme_benvi() will use Poppins automatically with ragg devices.

Common mistakes

  1. Hard-importing extrafont or showtext. Place font packages in Suggests and guard calls with requireNamespace(). A hard import means your package gets archived if the dependency does.

  2. Hardcoding a font name with no fallback. hrbrthemes v0.9.3 defaults theme_ipsum_rc() to "Roboto Condensed" with no detection. Users without the font get silent rendering failures.

  3. Using .onAttach() instead of .onLoad(). .onAttach() does not fire when another package imports yours or during devtools::load_all().

  4. Not wrapping register_font() in tryCatch(). It errors if the font is already system-installed.

  5. Forgetting that R CMD check uses pdf(). This is the single most common CRAN rejection reason for font-dependent packages.

  6. Relying on \donttest{} for protection. CRAN runs \donttest{} code. Use \dontrun{} for font-dependent examples.

  7. Wrapping ALL examples in \dontrun{}. CRAN requires at least one runnable example per exported function. Also, maintaining a package where examples are skipped by default has a higher chance of leading to unexpected errors.

The remaining gap

The biggest limitation of the systemfonts + ragg stack is that PDF output is not yet supported. The systemfonts vignette acknowledges this explicitly: “You might notice there’s currently a big hole in this workflow: PDFs.” Users who need custom fonts in PDF currently have three options.

  • cairo_pdf() – works if the font is installed system-wide (not just registered)
  • showtext – renders text as polygon outlines (font works but text is not selectable)
  • ragg at high DPIragg::agg_png() at 300+ DPI produces publication-quality bitmap output

PDF support in systemfonts is planned but has no timeline. Until then, documenting this limitation clearly is the best you can do.

Summary

Font management in R packages is harder than it should be, but the systemfonts + ragg stack provides a clean path forward. The key insights are that R has three independent font resolution systems, that R CMD check uses pdf() which only knows about 14 font families, and that examples must be explicitly protected from font-dependent code.

The workflow described here – bundle fonts, register in .onLoad(), gate on ragg, default to "sans", protect examples – delivers the best of both worlds. Users with ragg get the custom font automatically. CRAN gets examples that run cleanly. And you avoid the maintenance risk of depending on fragile packages like extrafont.

Footnotes

  1. I recommend consulting the Function documentation chapter of the R Packages book.↩︎