Skip to content

Commit 89204bc

Browse files
authored
Device capabilities checker (#5350)
* backport isTRUE * Implement `check_device()` * Document * Write tests * Add NEWS bullet * Wrap error-throwing example * skip test for dev capabilities on R < 4.2.0 * Check next device when RStudioGD * Allow testing for specific blending/compositing operation * More vdiffr nuance * Redocument * Misplaced parenthesis * Get out of pickle * Add `maybe` argument * Don't internally discriminate blending/compositing with `op` argument * Cleaner vdiffr solution * Polish docs a bit
1 parent 88d0517 commit 89204bc

File tree

7 files changed

+569
-2
lines changed

7 files changed

+569
-2
lines changed

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Suggests:
6767
sf (>= 0.7-3),
6868
svglite (>= 1.2.0.9001),
6969
testthat (>= 3.1.2),
70-
vdiffr (>= 1.0.0),
70+
vdiffr (>= 1.0.6),
7171
xml2
7272
Enhances:
7373
sp

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ export(benchplot)
291291
export(binned_scale)
292292
export(borders)
293293
export(calc_element)
294+
export(check_device)
294295
export(combine_vars)
295296
export(continuous_scale)
296297
export(coord_cartesian)

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# ggplot2 (development version)
22

3+
* New function `check_device()` for testing the availability of advanced
4+
graphics features introduced in R 4.1.0 onwards (@teunbrand, #5332).
5+
36
* Failing to fit or predict in `stat_smooth()` now gives a warning and omits
47
the failed group, instead of throwing an error (@teunbrand, #5352).
58

R/backports.R

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ if (getRversion() < "3.3") {
1717

1818
on_load(backport_unit_methods())
1919

20-
# isFALSE() is available on R (>=3.5)
20+
# isFALSE() and isTRUE() are available on R (>=3.5)
2121
if (getRversion() < "3.5") {
2222
isFALSE <- function(x) is.logical(x) && length(x) == 1L && !is.na(x) && !x
23+
isTRUE <- function(x) is.logical(x) && length(x) == 1L && !is.na(x) && x
2324
}

R/utilities-checks.R

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,330 @@ check_inherits <- function(x,
6868
call = call
6969
)
7070
}
71+
72+
#' Check graphics device capabilities
73+
#'
74+
#' This function makes an attempt to estimate whether the graphics device is
75+
#' able to render newer graphics features.
76+
#'
77+
#' @param feature A string naming a graphics device feature. One of:
78+
#' `"clippingPaths"`, `"alpha_masks"`, `"lumi_masks"`, `"compositing"`,
79+
#' `"blending"`, `"transformations"`, `"gradients"`, `"patterns"`, `"paths"`
80+
#' or `"glyphs"`. See the 'Features' section below for an explanation
81+
#' of these terms.
82+
#' @param action A string for what action to take. One of:
83+
#' * `"test"` returns `TRUE` or `FALSE` indicating support of the feature.
84+
#' * `"warn"` also returns a logical, but throws an informative warning when
85+
#' `FALSE`.
86+
#' * `"abort"` throws an error when the device is estimated to not support
87+
#' the feature.
88+
#' @param op A string for a specific operation to test for when `feature` is
89+
#' either `"blending"` or `"compositing"`. If `NULL` (default), support for
90+
#' all known blending or compositing operations is queried.
91+
#' @param maybe A logical of length 1 determining what the return value should
92+
#' be in case the device capabilities cannot be assessed.
93+
#' @param call The execution environment of a currently running function, e.g.
94+
#' [`caller_env()`][rlang::caller_env()]. The function will be mentioned in
95+
#' warnings and error messages as the source of the warning or error. See
96+
#' the `call` argument of [`abort()`][rlang::abort()] for more information.
97+
#'
98+
#' @details
99+
#' The procedure for testing is as follows:
100+
#'
101+
#' * First, the \R version is checked against the version wherein a feature was
102+
#' introduced.
103+
#' * Next, the [dev.capabilities()][grDevices::dev.capabilities()] function is
104+
#' queried for support of the feature.
105+
#' * If that check is ambiguous, the \pkg{svglite} and \pkg{ragg} devices are
106+
#' checked for known support.
107+
#' * Lastly, if there is no answer yet, it is checked whether the device is one
108+
#' of the 'known' devices that supports a feature.
109+
#'
110+
#' @section Features:
111+
#' \describe{
112+
#' \item{`"clippingPaths"`}{While most devices support rectangular clipping
113+
#' regions, this feature is about the support for clipping to arbitrary paths.
114+
#' It can be used to only display a part of a drawing.}
115+
#' \item{`"alpha_masks"`}{Like clipping regions and paths, alpha masks can also
116+
#' be used to only display a part of a drawing. In particular a
117+
#' semi-transparent mask can be used to display a drawing in the opaque parts
118+
#' of the mask and hide a drawing in transparent part of a mask.}
119+
#' \item{`"lumi_masks`}{Similar to alpha masks, but using the mask's luminance
120+
#' (greyscale value) to determine what is drawn. Light values are opaque and
121+
#' dark values are transparent.}
122+
#' \item{`"compositing"`}{Compositing allows one to control how to drawings
123+
#' are drawn in relation to one another. By default, one drawing is drawn
124+
#' 'over' the previous one, but other operators are possible, like 'clear',
125+
#' 'in' and 'out'.}
126+
#' \item{`"blending"`}{When placing one drawing atop of another, the blend
127+
#' mode determines how the colours of the drawings relate to one another.}
128+
#' \item{`"transformations"`}{Performing an affine transformation on a group
129+
#' can be used to translate, rotate, scale, shear and flip the drawing.}
130+
#' \item{`"gradients"`}{Gradients can be used to show a transition between
131+
#' two or more colours as a fill in a drawing. The checks expects both linear
132+
#' and radial gradients to be supported.}
133+
#' \item{`"patterns"`}{Patterns can be used to display a repeated, tiled
134+
#' drawing as a fill in another drawing.}
135+
#' \item{`"paths"`}{Contrary to 'paths' as polyline or polygon drawings,
136+
#' `"paths"` refers to the ability to fill and stroke collections of
137+
#' drawings.}
138+
#' \item{`"glyphs"`}{Refers to the advanced typesetting feature for
139+
#' controlling the appearance of individual glyphs.}
140+
#' }
141+
#'
142+
#' @section Limitations:
143+
#'
144+
#' * On Windows machines, bitmap devices such as `png()` or `jpeg()` default
145+
#' to `type = "windows"`. At the time of writing, these don't support any
146+
#' new features, in contrast to `type = "cairo"`, which does. Prior to \R
147+
#' version 4.2.0, the capabilities cannot be resolved and the value of the
148+
#' `maybe` argument is returned.
149+
#' * With the exception of the \pkg{ragg} and \pkg{svglite} devices, if the
150+
#' device doesn't report their capabilities via
151+
#' [dev.capabilities()][grDevices::dev.capabilities()], or the \R version is
152+
#' below 4.2.0, the `maybe` value is returned.
153+
#' * Even though patterns and gradients where introduced in \R 4.1.0, they
154+
#' are considered unsupported because providing vectorised patterns and
155+
#' gradients was only introduced later in \R 4.2.0.
156+
#' * When using the RStudio graphics device, the back end is assumed to be the
157+
#' next device on the list. This assumption is typically met by default,
158+
#' unless the device list is purposefully rearranged.
159+
#'
160+
#' @return `TRUE` when the feature is thought to be supported and `FALSE`
161+
#' otherwise.
162+
#' @export
163+
#' @keywords internal
164+
#'
165+
#' @examples
166+
#' # Typically you'd run `check_device()` inside a function that might produce
167+
#' # advanced graphics.
168+
#' # The check is designed for use in control flow statements in the test mode
169+
#' if (check_device("patterns", action = "test")) {
170+
#' print("Yay")
171+
#' } else {
172+
#' print("Nay")
173+
#' }
174+
#'
175+
#' # Automatically throw a warning when unavailable
176+
#' if (check_device("compositing", action = "warn")) {
177+
#' print("Yay")
178+
#' } else {
179+
#' print("Nay")
180+
#' }
181+
#'
182+
#' # Possibly throw an error
183+
#' try(check_device("glyphs", action = "abort"))
184+
check_device = function(feature, action = "warn", op = NULL, maybe = FALSE,
185+
call = caller_env()) {
186+
187+
check_bool(maybe, allow_na = TRUE)
188+
189+
action <- arg_match0(action, c("test", "warn", "abort"))
190+
action_fun <- switch(
191+
action,
192+
warn = cli::cli_warn,
193+
abort = cli::cli_abort,
194+
function(...) invisible()
195+
)
196+
197+
feature <- arg_match0(
198+
feature,
199+
c("clippingPaths", "alpha_masks", "lumi_masks", "compositing", "blending",
200+
"transformations", "glyphs", "patterns", "gradients", "paths",
201+
".test_feature")
202+
)
203+
# Formatting prettier feature names
204+
feat_name <- switch(
205+
feature,
206+
clippingPaths = "clipping paths",
207+
patterns = "tiled patterns",
208+
blending = "blend modes",
209+
gradients = "colour gradients",
210+
glyphs = "typeset glyphs",
211+
paths = "stroking and filling paths",
212+
transformations = "affine transformations",
213+
alpha_masks = "alpha masks",
214+
lumi_masks = "luminance masks",
215+
feature
216+
)
217+
218+
# Perform version check
219+
version <- getRversion()
220+
capable <- switch(
221+
feature,
222+
glyphs = version >= "4.3.0",
223+
paths =, transformations =, compositing =,
224+
patterns =, lumi_masks =, blending =,
225+
gradients = version >= "4.2.0",
226+
alpha_masks =,
227+
clippingPaths = version >= "4.1.0",
228+
TRUE
229+
)
230+
if (isFALSE(capable)) {
231+
action_fun("R {version} does not support {.emph {feature}}.",
232+
call = call)
233+
return(FALSE)
234+
}
235+
236+
# Grab device for checking
237+
dev_cur <- grDevices::dev.cur()
238+
dev_name <- names(dev_cur)
239+
240+
if (dev_name == "RStudioGD") {
241+
# RStudio opens RStudioGD as the active graphics device, but the back-end
242+
# appears to be the *next* device. Temporarily set the next device as the
243+
# device to check capabilities.
244+
dev_old <- dev_cur
245+
on.exit(grDevices::dev.set(dev_old), add = TRUE)
246+
dev_cur <- grDevices::dev.set(grDevices::dev.next())
247+
dev_name <- names(dev_cur)
248+
}
249+
250+
# For blending/compositing, maybe test a specific operation
251+
if (!is.null(op) && feature %in% c("blending", "compositing")) {
252+
op <- arg_match0(op, c(.blend_ops, .compo_ops))
253+
.blend_ops <- .compo_ops <- op
254+
feat_name <- paste0("'", gsub("\\.", " ", op), "' ", feat_name)
255+
}
256+
257+
# The dev.capabilities() approach may work from R 4.2.0 onwards
258+
if (version >= "4.2.0") {
259+
capa <- grDevices::dev.capabilities()
260+
261+
# Test if device explicitly states that it is capable of this feature
262+
capable <- switch(
263+
feature,
264+
clippingPaths = isTRUE(capa$clippingPaths),
265+
gradients = all(c("LinearGradient", "RadialGradient") %in% capa$patterns),
266+
alpha_masks = "alpha" %in% capa$masks,
267+
lumi_masks = "luminance" %in% capa$masks,
268+
patterns = "TilingPattern" %in% capa$patterns,
269+
compositing = all(.compo_ops %in% capa$compositing),
270+
blending = all(.blend_ops %in% capa$compositing),
271+
transformations = isTRUE(capa$transformations),
272+
paths = isTRUE(capa$paths),
273+
glyphs = isTRUE(capa$glyphs),
274+
NA
275+
)
276+
if (isTRUE(capable)) {
277+
return(TRUE)
278+
}
279+
280+
# Test if device explicitly denies that it is capable of this feature
281+
incapable <- switch(
282+
feature,
283+
clippingPaths = isFALSE(capa$clippingPaths),
284+
gradients = !all(is.na(capa$patterns)) &&
285+
!all(c("LinearGradient", "RadialGradient") %in% capa$patterns),
286+
alpha_masks = !is.na(capa$masks) && !("alpha" %in% capa$masks),
287+
lumi_masks = !is.na(capa$masks) && !("luminance" %in% capa$masks),
288+
patterns = !is.na(capa$patterns) && !("TilingPattern" %in% capa$patterns),
289+
compositing = !all(is.na(capa$compositing)) &&
290+
!all(.compo_ops %in% capa$compositing),
291+
blending = !all(is.na(capa$compositing)) &&
292+
!all(.blend_ops %in% capa$compositing),
293+
transformations = isFALSE(capa$transformations),
294+
paths = isFALSE(capa$paths),
295+
glyphs = isFALSE(capa$glyphs),
296+
NA
297+
)
298+
299+
if (isTRUE(incapable)) {
300+
action_fun(
301+
"The {.field {dev_name}} device does not support {.emph {feat_name}}.",
302+
call = call
303+
)
304+
return(FALSE)
305+
}
306+
}
307+
308+
# Test {ragg}'s capabilities
309+
if (dev_name %in% c("agg_jpeg", "agg_ppm", "agg_png", "agg_tiff")) {
310+
# We return ragg's version number if not installed, so we can suggest to
311+
# install it.
312+
capable <- switch(
313+
feature,
314+
clippingPaths =, alpha_masks =, gradients =,
315+
patterns = if (is_installed("ragg", version = "1.2.0")) TRUE else "1.2.0",
316+
FALSE
317+
)
318+
if (isTRUE(capable)) {
319+
return(TRUE)
320+
}
321+
if (is.character(capable) && action != "test") {
322+
check_installed(
323+
"ragg", version = capable,
324+
reason = paste0("for graphics support of ", feat_name, ".")
325+
)
326+
}
327+
action_fun(paste0(
328+
"The {.pkg ragg} package's {.field {dev_name}} device does not support ",
329+
"{.emph {feat_name}}."
330+
), call = call)
331+
return(FALSE)
332+
}
333+
334+
# The vdiffr version of the SVG device is known to not support any newer
335+
# features
336+
if (dev_name == "devSVG_vdiffr") {
337+
action_fun(
338+
"The {.pkg vdiffr} package's device does not support {.emph {feat_name}}.",
339+
call = call
340+
)
341+
return(FALSE)
342+
}
343+
344+
# The same logic applies to {svglite} but is tested separately in case
345+
# {ragg} and {svglite} diverge at some point.
346+
if (dev_name == "devSVG") {
347+
# We'll return a version number if not installed so we can suggest it
348+
capable <- switch(
349+
feature,
350+
clippingPaths =, gradients =, alpha_masks =,
351+
patterns = if (is_installed("svglite", version = "2.1.0")) TRUE else "2.1.0",
352+
FALSE
353+
)
354+
355+
if (isTRUE(capable)) {
356+
return(TRUE)
357+
}
358+
if (is.character(capable) && action != "test") {
359+
check_installed(
360+
"svglite", version = capable,
361+
reason = paste0("for graphics support of ", feat_name, ".")
362+
)
363+
}
364+
action_fun(paste0(
365+
"The {.pkg {pkg}} package's {.field {dev_name}} device does not ",
366+
"support {.emph {feat_name}}."), call = call
367+
)
368+
return(FALSE)
369+
}
370+
371+
# Last resort: list of known support prior to R 4.2.0
372+
supported <- c("pdf", "cairo_pdf", "cairo_ps", "svg")
373+
if (feature == "compositing") {
374+
supported <- setdiff(supported, "pdf")
375+
}
376+
if (.Platform$OS.type == "unix") {
377+
# These devices *can* be supported on Windows, but would have to have
378+
# type = "cairo", which we can't check.
379+
supported <- c(supported, "bmp", "jpeg", "png", "tiff")
380+
}
381+
if (isTRUE(dev_name %in% supported)) {
382+
return(TRUE)
383+
}
384+
action_fun(
385+
"Unable to check the capabilities of the {.field {dev_name}} device.",
386+
call = call
387+
)
388+
return(maybe)
389+
}
390+
391+
.compo_ops <- c("clear", "source", "over", "in", "out", "atop", "dest",
392+
"dest.over", "dest.in", "dest.out", "dest.atop", "xor", "add",
393+
"saturate")
394+
395+
.blend_ops <- c("multiply", "screen", "overlay", "darken", "lighten",
396+
"color.dodge", "color.burn", "hard.light", "soft.light",
397+
"difference", "exclusion")

0 commit comments

Comments
 (0)