names(pdfFonts())
#> "serif" "sans" "mono" "AvantGarde" "Bookman" "Courier"
#> "Helvetica" "Helvetica-Narrow" "NewCenturySchoolbook" "Palatino" ...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.
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.
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. Thettf2pt1binary is abandonware. It processes fonts sequentially withsystem2()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) reportfont_import()skipping all fonts with “No FontName. Skipping.”- Database corruption. Font metadata lives in a CSV file inside the installed
extrafontdbpackage 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.
systemfonts + ragg – the recommended approach
This is the recommended path. systemfonts::register_font() stores font file paths in a C++ map. When a ragg device renders text, it calls systemfonts::locate_font() to find the right file, then renders via FreeType with full HarfBuzz text shaping (ligatures, kerning).
The limitation is that only ragg and svglite devices can see registered fonts. The pdf() device cannot. But this is manageable with the right example strategy.
| Aspect | extrafont | showtext | systemfonts + ragg |
|---|---|---|---|
| PDF device support | Yes | Yes (polygon outlines) | No (planned) |
| Text selectable in PDF | Yes | No | N/A |
| Text shaping | None | None | Full (HarfBuzz) |
| Session setup | Every session | Every session | .onLoad() suffices |
| Write to pkg dirs | Yes | No | No |
| CRAN archival risk | High | Low | Low |
| Maintainer | Single (new) | Single | Posit team |
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
)
}We initially tried three weaker checks, all of which failed.
- Registry-only check –
systemfonts::registry_fonts()confirms the font is registered, butpdf()ignores it. Failed on win-builder. - System fonts check –
systemfonts::system_fonts()uses CoreText on macOS, which is not the same as the PostScript font database. Failed locally withdevtools::check(). - 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")
#' }\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
Hard-importing extrafont or showtext. Place font packages in
Suggestsand guard calls withrequireNamespace(). A hard import means your package gets archived if the dependency does.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.Using
.onAttach()instead of.onLoad()..onAttach()does not fire when another package imports yours or duringdevtools::load_all().Not wrapping
register_font()intryCatch(). It errors if the font is already system-installed.Forgetting that R CMD check uses
pdf(). This is the single most common CRAN rejection reason for font-dependent packages.Relying on
\donttest{}for protection. CRAN runs\donttest{}code. Use\dontrun{}for font-dependent examples.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 DPI –
ragg::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
I recommend consulting the Function documentation chapter of the R Packages book.↩︎