Becoming an R Graphics Groupie:
Groups, Compositing Operators, and Affine Transformations in R Graphics

by Paul Murrell http://orcid.org/0000-0002-3224-8858

Version 2: Monday 30 May 2022

Version 1: Monday 15 November 2021
Version 2: Removed "Accumulating transformations"; added "The vp argument"; added "Appendix".


Creative Commons License
This document by Paul Murrell is licensed under a Creative Commons Attribution 4.0 International License.


This document describes an expansion of the R graphics engine to support a number of new graphical features: isolated groups, compositing operators, and affine transformations.

These features are available in R version 4.2.0.

R users wanting to try out the new graphics features should start with the User API Section, which provides a quick introduction to the new R-level interface.

Maintainers of R packages that provide R graphics devices should read the Device API Section, which provides a description of the required changes to R graphics devices.

These new graphics features have not (yet) been implemented for all of the graphics devices provided by the 'grDevices' package. Devices that do support the new features are the pdf() graphics device and Cairo graphics devices: x11(type="cairo"), cairo_pdf(), cairo_ps(), png(type="cairo"), jpeg(type="cairo"), tiff(type="cairo"), and svg(). The remainder of the graphics devices in 'grDevices' will run, but will (silently) not produce the correct output. Graphics devices from other R packages should be reinstalled and will not produce the correct output until (or unless) the package maintainer adds support.

Table of Contents:

1. Introduction

Changes to the graphics engine in R 4.1.0 added support for gradient and pattern fills, clipping paths, and masks (Murrell, 2020b). One way to think of those changes is that they created an R interface to some of the more advanced graphical features of specific R graphics devices - graphics devices that are based on sophisticated graphics systems, like the pdf() device that is based on the Adobe Portable Document Format (Adobe Systems Incorporated, 2001) and the graphics devices based on the Cairo graphics library (Packard et al., 2021). This document describes another step along that path, by adding an R interface to work with "groups" of graphical objects.

As a simple example of the increased sophistication provided by the new features, consider the following code, which draws two opaque filled circles that partially overlap each other on top of a piece of text (the text is completely obscured).

library(grid)
grid.text("background")
c <- circleGrob(1:2/3, r=.3, gp=gpar(col=2:3, fill=2:3))
grid.draw(c)
plot of chunk unnamed-chunk-4

We will now draw the same circles (and text), but this time in a viewport with a semitransparency mask.

grid.text("background")
mask <- rectGrob(gp=gpar(col=NA, fill=rgb(0,0,0,.5)))
pushViewport(viewport(mask=mask))
grid.draw(c)
plot of chunk unnamed-chunk-5

The result is that each circle is drawn with the mask applied, so each circle becomes semitransparent. The circles overlap so we get a region where the first circle is partially visible beneath the second circle. The text is also visible beneath the circles.

Now consider the following code, which draws the circles, but this time as a "group", again in a viewport with a semitransparency mask.

grid.text("background")
pushViewport(viewport(mask=mask))
grid.group(c)
plot of chunk unnamed-chunk-6

The difference is that the opaque circles are drawn together first as an isolated group, with the green circle partially obscuring the red circle (as in the original drawing), and only then is the mask applied. The mask has been applied to the result of drawing the group of circles rather than being applied to each circle as it is drawn. The text is again visible beneath the circles to show that the area of intersection between the circles is only the green circle made semitransparent.

This captures the essence of the new graphics features that are provided in this report: we get to draw a group of objects in isolation before adding the group to the overall image.

The image below provides a more dramatic demonstration. Here we have a 'ggplot2' plot (Wickham, 2016) treated as an isolated group. The group has been drawn twice: once as it would appear normally (upright) and once with a shear transformation applied (to give the impression of a shadow that is cast by the plot). This hints at the fact that, once we have defined a group of shapes in isolation, there are a number of new effects that we can achieve. The code for the image below will be shown later once we have a better idea of how the new graphics functions work.

plot of chunk unnamed-chunk-7

The User API Section provides simple examples that demonstrate all of the new user-level features and demonstrate some of the effects that can be obtained from them.

The Section Exploring the new features goes into more detail about how the new user-level functions work. This is not necessary for simple usage, but there are useful details for more sophisticated use of the new features.

The new features are only implemented on a few of the standard R graphics devices so far (the pdf() device and the devices based on the Cairo graphics system); the Device API Section describes the interface that graphics devices must implement if they want to support these features.

The simple overlapping-circles examples above are representative of most examples in this report; they are simple demonstrations of graphical features, with no obvious connection to data visualisation. The main motivation for adding the new features to the R graphics engine is to reduce the need for users to have to manually tweak R graphics output in other systems like Adobe Illustrator. In other words, the aim of these changes is to encourage users to generate graphical output entirely in code, with all of the benefits of reproducibility, sharing, version control etc that come with working entirely in code. Direct applications of these features to data visualisation will hopefully follow as users and developers experiment with the new possibilities.

2. User API

The first new function to describe is the grid.group() function. As shown in the Introduction, this function takes a 'grid' grob, renders it in isolation, and then combines it with the main image.

The isolated drawing can be arbitrarily complex, involving multiple grobs and viewports, by providing a gTree as the first argument. For example, the following code draws a rectangle and a circle as a group.

r <- rectGrob(1/3, 2/3, width=.5, height=.5, gp=gpar(fill="black"))
c <- circleGrob(2/3, 1/3, r=.3, gp=gpar(fill="black"))
gt <- gTree(children=gList(r, c))
grid.group(gt)
plot of chunk unnamed-chunk-8

The following code draws the entire gTree from a 'ggplot2' plot as a group.

library(ggplot2)
gg <- ggplotGrob(ggplot(mtcars) + geom_point(aes(disp, mpg)))
grid.group(gg)
plot of chunk unnamed-chunk-10

The above results are exactly the same as the results we would get by just drawing the grobs normally, as the code below shows for the simple rectangle-plus-circle example.

grid.draw(gt)
plot of chunk unnamed-chunk-11

We get the same result in the examples above because, when we draw the shapes as a group, we begin with a temporary transparent canvas, we draw the group on this temporary canvas, and then the result is drawn on top of any previous drawing in the main image However, there are several ways in which we can vary that situation.

Masking

If we enforce a mask (in the main image), the results of drawing normally and drawing as a group are no longer the same. This was the first example shown in the introduction. The code below repeats the example with the rectangle and circle, but now with a mask in place. Some text is drawn in the background so that we can more easily see the transparency of the shapes.

First we draw normally. The text is drawn in the background, then we push a viewport that enforces a semitransparent mask. The rectangle is drawn and, because of the mask, it is semitransparent, so we can see the text beneath it. Then the circle is drawn and it is also semitransparent, so we can see the text beneath it and, where the circle and the rectangle overlap, we can also see the rectangle beneath the cirlce.

grid.text("background")
pushViewport(viewport(mask=mask))
grid.draw(gt)
plot of chunk unnamed-chunk-12

Now we draw the shapes as a group. As before, the text is drawn and a viewport is pushed to enforce a mask. However, the rectangle and circle are now drawn as an isolated group, as a separate step. The result of drawing the rectangle and circle is then added to the main image, with the mask now in effect, so we get the result of drawing the rectangle and circle with no mask (the opaque union of the rectangle and circle) added to the main image using a mask, which produces a semitransparent union of the rectangle and circle. We can see the text beneath, but there is no visible overlap between the rectangle and circle.

grid.text("background")
pushViewport(viewport(mask=mask))
grid.group(gt)
plot of chunk unnamed-chunk-13

Compositing and blend modes

Another way that we can get a different result from drawing shapes in a group is that we can vary how shapes are drawn on top of each other (in the group).

By default, each new shape is drawn on top of any previous drawing, with the new shape obscuring previous drawing where there is any overlap. The grid.group() function has two further arguments, op and dst (the first argument is called src), which allow us to combine new shapes with previous shapes in a variety of ways. The dst argument is a 'grid' grob (like the src argument) and this is drawn first. The op argument selects a "compositing operator" and this specifies how the src grob is combined with the dst. The default op value is "over".

The following code uses the same simple shapes in the group as before (a rectangle and a circle), but now, within the group, we draw the rectangle first (as dst) and then the circle (as src), using a "clear" operator. With this operator, the new shape "clears" the previous drawing wherever there is an overlap. In this case, drawing the circle over the rectangle takes a bite out of the bottom-right corner of the rectangle. With the "clear" operator, the src is not drawn at all.

grid.group(c, "clear", r)
plot of chunk unnamed-chunk-14

By default, the dst is a fully transparent rectangle, but we can easily specify a different "backdrop" for a group. The following code specifies a grey filled rectangle as the dst and then draws the gTree consisting of the rectangle and the circle on top, using a "clear" operator. The result is a hole punched in the grey rectangle based on the black rectangle and the black circle. To show that the result of drawing this group is NOT the same as drawing a white rectangle and a white circle on top of the grey rectangle, we first drew some text in the main image; when the group is drawn on top of the main image we can see the text through the hole that the rectangle and circle have punched in the grey rectangle.

grid.text("reveal", gp=gpar(cex=4))
grid.group(gt, "clear", rectGrob(gp=gpar(col= NA, fill="grey")))
plot of chunk unnamed-chunk-15

All of the compositing operators supported by the Cairo graphics system are provided (including the standard Porter-Duff operators; Porter and Duff, 1984), but only a subset of these are supported on the PDF graphics device. In the PDF language, the compositing operators are called "blend modes"; the default "over" operator corresponds to "Normal" blend mode and several other operators correspond to the other PDF blend modes, but the "clear" operator, for example, has no corresponding blend mode. Where there is no correspondence, the PDF device falls back to "Normal" blend mode.

The full list of available operators are shown below.

   [1] "clear"       "source"      "over"        "in"          "out"         "atop"       
   [7] "dest"        "dest.over"   "dest.in"     "dest.out"    "dest.atop"   "xor"        
  [13] "add"         "saturate"    "multiply"    "screen"      "overlay"     "darken"     
  [19] "lighten"     "color.dodge" "color.burn"  "hard.light"  "soft.light"  "difference" 
  [25] "exclusion"

Reusable groups

In addition to grid.group(), there are two other new functions: grid.define() and grid.use(). The purpose of these functions is to separate the definition of a group from its use. This allows us to reuse a group multiple times from a single definition.

As an example, the following code defines a group consisting of a circle and a rectangular, similar to the one we have been using, but smaller.

r2 <- rectGrob(width=unit(1, "cm"), height=unit(1, "cm"),
               hjust=.75, vjust=.25,
               just=c("right", "bottom"),
               gp=gpar(fill="black"))
c2 <- circleGrob(x=unit(.5, "npc") + unit(.5, "cm"),
                 y=unit(.5, "npc") - unit(.5, "cm"),
                 r=unit(.67, "cm"),
                 gp=gpar(fill="black"))
gt2 <- gTree(children=gList(r2, c2))
grid.group(gt2)
plot of chunk unnamed-chunk-18

In the following code, we first call grid.define() to define the group and give it a name, "group1". This does not draw the group, it just defines the group on the graphics device. We then call grid.use() with the name of the group we want to use, and this draws the group. This produces the drawing in the centre of the page, just like grid.group() did and demonstrates that grid.group() is really just a wrapper for grid.define() and grid.use().

The interesting part happens next. We push a viewport in a different location on the page (the bottom-left corner) and call grid.use() again. This draws the group in a different location on the page. Then we repeat the dose to draw the group in yet another location (the top-right corner).

grid.define(gt2, name="group1")
grid.use("group1")
pushViewport(viewport(.25, .25, gp=gpar(col=2)))
grid.use("group1")
popViewport()
pushViewport(viewport(.75, .75, gp=gpar(col=3)))
grid.use("group1")
popViewport()
plot of chunk unnamed-chunk-19

One potential benefit of this feature is efficiency on the graphics device. For example, the PDF device records the group definition only once and then can just refer to the definition every time the group is drawn.

A more interesting detail about the example above is that the grid.use calls are applying a translation to the group to redraw it in different locations on the page. This opens up a whole new world of possibilities.

Affine transformations

The following code performs the same two initial steps as the last example: we define a group and the use it. This draws the group in the centre of the page. However, the next step is different. Here we push a viewport that is in the bottom-left corner of the page and is smaller than the original viewport that we defined the group in. This difference in both viewport location and size induces both a translation due to the different location of the viewport and a scaling transformation due to the different size of the viewport. The result is that the original group is drawn scaled down (and translated). The final step demonstrates the scaling more dramatically. This time we have a viewport at a different location (top-right) and with a different size and with a different aspect ratio (twice as wide as it is high). The result is that the original group is drawn scaled and distorted (the circle has become an ellipse).

grid.define(gt2, name="group1")
grid.use("group1")
pushViewport(viewport(.25, .25, width=.5, height=.5))
grid.use("group1")
popViewport()
pushViewport(viewport(.75, .75, height=.5))
grid.use("group1")
popViewport()
plot of chunk unnamed-chunk-20

It is important to note that these transformations are different from what would normally happen when we draw 'grid' objects. The transformations are happening on the device rather than in 'grid'. The following code is identical to the code above except that it calls grid.draw(), which draws the grob gt2 normally, rather than grid.use(), which draws the group "group1" that was defined based on the grob gt2. When we draw gt2 normally, it remains the same size, regardless of which viewport we draw it in, because the width and height of the rectangle and the radius of the circle were specified in absolute units (cm).

grid.define(gt2, name="group1")
grid.draw(gt2)
pushViewport(viewport(.25, .25, width=.5, height=.5))
grid.draw(gt2)
popViewport()
pushViewport(viewport(.75, .75, height=.5))
grid.draw(gt2)
popViewport()
plot of chunk unnamed-chunk-21

The behaviour of affine transformations is explored in more detail in the next section, along with several other aspects of drawing groups.

3. Exploring the new features

This section goes into further detail about how the new graphics engine features work. It is not necessary for basic usage, but is required to achieve more complex results and may be helpful to understand the output when an unexpected result occurs.

Group names and persistence

When we define a group with grid.define(), we give it a name, which we can then use to refer to the group in a call to grid.use(). These names are unique per device.

If we define a new group with the same name as an existing group, the new group replaces the old group. For example, the following code defines a group called "a" based on a rectangle, then defines another group called "a" based on a circle. When we subsequently use the group called "a", the result is a circle.

grid.define(rectGrob(), name="a")
grid.define(circleGrob(), name="a")
grid.use("a")
plot of chunk unnamed-chunk-22

Group definitions are usually erased at the start of a new page. However, the grid.newpage() function has a clearGroups argument. If that is set to FALSE then a group can be defined on one page and reused on another page.

Graphical parameter settings

The grobs within a group may specify some explicit graphical parameter settings and the group will inherit graphical parameter settings from the context in which the group is defined. However, both explicit and inherited settings are then fixed for any use of the group.

For example, in the code below, we first push a viewport (on the left) that has an explicit line width (2) and colour (red). We draw a rectangle with no explicit graphical parameter settings to show that, in normal 'grid' drawing, the settings from the viewport provide defaults for any drawing within the viewport; the rectangle is drawn with a thin red border. Within the same viewport, we also define a group based on a circle with an explicit line width setting (5). This group ignores the line width default, because it has its own explicit line width, but inherits the default colour red. When we then use the group, we get a (thick) red circle. Next, we push a new viewport (on the right) with green as the default colour and a thicker default line width (5). As before, when we draw a rectangle with no explicit graphical parameter settings, it uses the defaults from the viewport, and we get a thick green rectangle. However, when we reuse the group within this viewport, the circle retains the (explicit) line width and the (inherited) colour from when it was defined and we still get a (thick) red circle.

pushViewport(viewport(x=1/4, width=.5, gp=gpar(lwd=2, col=2)))
grid.rect(width=.9, height=.9)
grid.define(circleGrob(r=.3), name="c", gp=gpar(lwd=5))
grid.use("c")
popViewport()
pushViewport(viewport(x=3/4, width=.5, gp=gpar(col="green")))
grid.rect(width=.9, height=.9, gp=gpar(lwd=5))
grid.use("c")
popViewport()
plot of chunk unnamed-chunk-23

Compositing and blend modes

The examples in the User API Section involved only compositing single shapes. This section looks at some subtleties about how groups behave when src and/or dst consist of more than one shape.

In simple terms, group drawing works by drawing dst, if it is not NULL, then setting the compositing operator, op, then drawing src. The result of all of that drawing is then combined with the main image. An important detail about group drawing is that the compositing operator op is in effect for all of the drawing of src. Another detail is that the default "over" operator is in effect for all of the drawing of dst. These details become apparent when src and/or dst consist of more than one shape.

For example, the following code draws a group, using the "xor" compositing operator, where both the src and dst consist of two overlapping circles. The src circles are both filled red and the dst circles are both filled green. The circles are positioned in this example so that drawing proceeds from left to right across the image. The left green circle is drawn first and then the right green circle is drawn on top using the default "over" operator. The compositing operator is then set to "xor" and the left red circle is drawn. This results in a gap where the right green circle and the left red circle overlap. Finally, the right red circle is drawn. The "xor" operator is still in effect, so the result is a gap where the left red circle and the right red circle overlap.

src <- circleGrob(3:4/5, r=.2, gp=gpar(col=NA, fill=2))
dst <- circleGrob(1:2/5, r=.2, gp=gpar(col=NA, fill=3))
grid.group(src, "xor", dst)
plot of chunk unnamed-chunk-24

The following code shows how we can isolate src so that the two red circles are combined using the default "over" operator before that result is then combined with dst using the "xor" operator. This is precisely what groups allow us to do. The result is only a gap where src and dst overlap.

grid.group(groupGrob(src), "xor", dst)
plot of chunk unnamed-chunk-25

The following code demonstrates that the op operator is in effect even when dst is NULL. Here we only draw a src, but it consists of two overlapping circles, and the operator is "xor", so the second circle is "xor"ed with the first circle.

grid.group(src, "xor")
plot of chunk unnamed-chunk-26

Combining graphics features

The User API Section described how groups can be used to produce a different result when a mask is in effect. This section looks at other combinations of groups with patterns and masks.

The following code shows that a group can be the basis for a tiling pattern. We create a group that combines a rotated rectangle on a black circle using the "clear" operator (creating a diamond-shaped hole in the circle) and then use that group as a repeating pattern to fill a rectangle. A red horizontal line is drawn first to illustrate that the diamond shape is a hole in the circle, not a white diamond drawn on top of the circle.

gp <- groupGrob(rectGrob(width=.1, height=.1, gp=gpar(fill="black"),
                         vp=viewport(angle=45)),
                "clear",
                circleGrob(r=.1, gp=gpar(fill="black")))
pat <- pattern(gp, width=.25, height=.25, extend="repeat")
grid.segments(y0=.5, y1=.5, gp=gpar(col=2, lwd=15))
grid.rect(width=.8, height=.8, gp=gpar(fill=pat))
plot of chunk unnamed-chunk-27

The following code shows that a group can also be the basis for a mask. We create a group that combines a semitransparent circle on a semitransparent rectangle, where the circle is more translucent than the rectangle. The group uses the "source" operator so that, where the circle and the rectangle overlap, only the circle is drawn. This produces a semitransparent rectangle with a more translucent circle in its centre. We push a viewport with this group as the mask and draw a series of thick, horizontal, green lines with the result that the semitransparency of the mask is transferred to the lines. A red diagonal line is drawn first to illustrate that the green lines are semitransparent.

gp <- groupGrob(circleGrob(r=.3, gp=gpar(col=NA, fill=rgb(0,0,0,.3))),
                "source",
                rectGrob(gp=gpar(fill=rgb(0,0,0,.7))))
grid.segments(gp=gpar(col=2, lwd=20))
pushViewport(viewport(mask=gp))
grid.segments(0, 1:4/5, 1, 1:4/5, gp=gpar(col=3, lwd=20))
plot of chunk unnamed-chunk-28

Affine transformations

As hinted at in the User API Section, group affine transformations work quite differently to the "normal" 'grid' transformations - the transformations that occur as a result of pushing 'grid' viewports and using the unit() function to associate locations and dimensions with different coordinate systems within the current viewport. The following code and output attempts to contrast the behaviour of groups with normal 'grid' behaviour.

We begin by defining some 'grid' grobs: a rectangle that has an absolute width (specified in inches); a rectangle that has a relative width (specified in "npc" coordinates); and a text label. We also define two 'grid' gTree objects, each of which contains one of the rectangles plus the text label.

r1 <- rectGrob(width=unit(.48, "in"), height=.6, gp=gpar(lwd=5))
r2 <- rectGrob(width=unit(.6, "npc"), height=.6, gp=gpar(lwd=5))
t <- textGrob("test")
gt1 <- gTree(children=gList(r1, t))
gt2 <- gTree(children=gList(r2, t))

The following code draws the first gTree normally, once in a square viewport and once in a wider viewport (as indicated by the dotted lines). The size of the rectangle does not change because its width is absolute. In more detail, the width of the rectangle, unit(.48, "in"), is evaluated when the rectangle is drawn and the result is the same in both viewports because .48in is the same regardless of the size of the viewport.

The text remains the same because it is drawn in the centre of the viewport and its size is absolute (12pt).

grid.newpage()
pushViewport(viewport(x=1/4, width=.2, height=.8))
grid.rect(gp=gpar(lty="dotted"))
grid.draw(gt1)
popViewport()
pushViewport(viewport(x=3/4, width=.4, height=.8))
grid.rect(gp=gpar(lty="dotted"))
grid.draw(gt1)
popViewport()
plot of chunk unnamed-chunk-30

The next code draws the second gTree normally, again in both a square viewport and a wider viewport. This time, because the width of the rectangle is relative, the rectangle is wider when it is drawn in the wider viewport; .6npc means 60% of the width of the parent viewport. The text is unchanged because its height (and width) are absolute.

pushViewport(viewport(x=1/4, width=.2, height=.8))
grid.rect(gp=gpar(lty="dotted"))
grid.draw(gt2)
popViewport()
pushViewport(viewport(x=3/4, width=.4, height=.8))
grid.rect(gp=gpar(lty="dotted"))
grid.draw(gt2)
popViewport()
plot of chunk unnamed-chunk-31

The next code demonstrates the different behaviour of group affine transformations. In the square viewport, we define a group based on the first gTree and draw (use) that group. This produces the same output as normal 'grid' drawing. In the wider rectangle, when we draw (reuse) the group, the difference between the square viewport (where the group was defined) and the wider viewport (where the group is being used) induces a transformation. The transformation includes a translation, because the wider viewport is to the right of the square viewport, and a scaling, because the wider viewport is wider than the square viewport. However, this transformation applies to everything that we draw: the rectangle is not only wider, but the lines that are used to draw the vertical sides of the rectangle are thicker. Furthermore, the text is stretched horizontally because of the scaling applied by the transformation.

pushViewport(viewport(x=1/4, width=.2, height=.8))
grid.rect(gp=gpar(lty="dotted"))
grid.define(gt1, name="group")
grid.use("group")
popViewport()
pushViewport(viewport(x=3/4, width=.4, height=.8))
grid.rect(gp=gpar(lty="dotted"))
grid.use("group")
popViewport()
plot of chunk unnamed-chunk-32

The "normal" 'grid' behaviour is useful because we do not, for example, usually want text to be distorted. However, the affine transformations that we get by reusing groups can be harnessed to achieve some useful effects. For example, the following code demonstrates that we can use affine transformations to fill a non-square region with a radial gradient. The main idea is that we define the gradient within a square region, but use it in a rectangular region. The result on the right is the image that we want to achieve and the result on the left is just for illustrative purposes. We do not need to use the gradient in the square region; we just need to define the gradient in the square region.

grad <- radialGradient(c("white", "black"))
r <- rectGrob(width=.6, height=.6, gp=gpar(col=NA, fill=grad))
pushViewport(viewport(x=1/4, width=.2, height=.8))
grid.rect(gp=gpar(lty="dotted"))
grid.define(r, name="rect")
grid.use("rect")
popViewport()
pushViewport(viewport(x=3/4, width=.4, height=.8))
grid.rect(gp=gpar(lty="dotted"))
grid.use("rect")
popViewport()
plot of chunk unnamed-chunk-33

Another issue that requires more explanation is the precise transformation that is induced by defining a group within one viewport and using the group within a different viewport. There are three aspects to consider: differences in viewport location, which induce a translation; differences in viewport size, which induce a scaling; and differences in viewport angle, which induce a rotation.

The first point is that the translation induced by a viewport is based on the (x, y) location of the viewport. In all previous examples, we have used viewports that are centred on their (x, y) location, but that does not have to be the case. For example, in the code below, we define a black rectangle within a viewport that is centred on (.5, .5) (and we draw the rectangle to show where it would appear if it was used within the same viewport), but we then use the black rectangle within a viewport that is bottom-left justified at (.25, .25). Both viewports have a width and height of .5, so they occupy the same region within the image (indicated by the dotted line), but the difference in the (x, y) location of the viewports (centre of the dotted region versus bottom-left of the dotted region) induces a translation and the reuse of the black rectangle draws it at a different location (the bottom-left of the dotted region, which is where it is used, instead of the centre of the dotted region, which is where it was defined).

pushViewport(viewport(width=.5, height=.5))
grid.rect(gp=gpar(lty="dotted"))
grid.define(rectGrob(width=.2, height=.2, gp=gpar(fill="black")),
            name="r")
grid.use("r")
popViewport()
pushViewport(viewport(x=.25, y=.25, width=.5, height=.5,
                      just=c("left", "bottom")))
grid.use("r")
popViewport()
plot of chunk unnamed-chunk-34

The translation is induced this way because it makes it easier to position the reuse of a group relative to a specific location using a justification other than "centred". For example, the following code defines a black rectangle bottom-left justified at the bottom-left corner of a viewport that is bottom-left justified at (.5, .5). We then use the rectangle within a viewport that is bottom-left justified at (.25, .25). This provides accurate control of the placement of the bottom-left corner of the group. (The previous example provided accurate control of the placement of the centre of the group.)

pushViewport(viewport(.5, .5, just=c("left", "bottom"),
                      width=.5, height=.5))
grid.rect(gp=gpar(lty="dotted", fill=NA))
grid.define(rectGrob(0, 0, just=c("left", "bottom"),
                     width=.2, height=.2,
                     gp=gpar(fill="black")),
            name="r")
grid.use("r")
popViewport()
pushViewport(viewport(.25, .25, just=c("left", "bottom"),
                      width=.5, height=.5))
grid.rect(gp=gpar(lty="dotted", fill=NA))
grid.use("r")
popViewport()
plot of chunk unnamed-chunk-35

The scaling and rotation transformations that are induced by differences in viewport size and angle are more straightforward, though it is worth pointing out that the differences in viewport size are differences in absolute size (in inches) and take no notice of the viewport scales.

It is also worth pointing out that the overall transformation that is induced by differences between viewports may involve a combination of translation, scaling, and/or rotation. The overall transformation is calculated by a translation from the original location (where the group was defined) to the origin (0, 0), followed by scaling, rotation, and then translation to the new location (where the group is to be used).

Custom transformations

Because the transformation is only dependent on differences in viewport location, size, and angle, it is not possible to induce the full range of affine transformations. For example, although we can distort a group, by applying a different amount of scaling in the x-direction compared to the y-direction, we cannot induce a shear transformation. However, the transform argument to the grid.use() function allows us to customise the transformation that will occur.

The transform argument must be a function that takes two arguments, group and device, and returns a 3x3 affine transformation matrix. In theory, we can provide any function that returns a 3x3 matrix (as long as the last column contains 0, 0, and 1), but generating a meaningful transformation matrix requires a good understanding of affine transformations and 'grid' internals. Several predefined functions are provided to make it easier to generate a custom transformation.

The default value of transform is the viewportTransform() function. This function automatically generates a transformation matrix based on the difference between the viewport where the group was defined and the viewport where the group is being used.

A simple customisation that we can perform is to use the shear argument to viewportTransform(). This allows us to specify a 3x3 matrix that describes a shear transform. A shear transformation is described by two parameters: the amount of shear in the x-direction and the amount of shear in the y-direction. The groupShear() function is provided to generate a matrix from just two values. For example, the following code defines a group in a viewport in the bottom-left corner of the image then reuses it in three viewports in each of the other three corners of the image. The different locations of the viewports induce a translation, but we also specify a custom transform in each reuse that is a function that adds a shear to the default viewport transformation. This results in different shear transformations of the rectangle (in addition to the default translations). Notice that the custom transform function uses an ellipsis argument (...) to pass through the group and device arguments.

pushViewport(viewport(.25, .25, width=.4, height=.4))
grid.rect(gp=gpar(lty="dotted"))
grid.define(rectGrob(width=.2, height=.2, gp=gpar(fill="black")),
            name="r")
grid.use("r")
popViewport()
pushViewport(viewport(.75, .25, width=.4, height=.4))
grid.rect(gp=gpar(lty="dotted"))
grid.use("r",
         trans=function(...) viewportTransform(...,
                                               shear=groupShear(sx=.5, sy=0)))
popViewport()
pushViewport(viewport(.25, .75, width=.4, height=.4))
grid.rect(gp=gpar(lty="dotted"))
grid.use("r",
         trans=function(...) viewportTransform(...,
                                               shear=groupShear(sx=0, sy=.5)))
popViewport()
pushViewport(viewport(.75, .75, width=.4, height=.4))
grid.rect(gp=gpar(lty="dotted"))
grid.use("r",
         trans=function(...) viewportTransform(...,
                                               shear=groupShear(sx=.5, sy=.5)))
popViewport()
plot of chunk unnamed-chunk-36

Another transformation that cannot be induced by differences between viewports is an inversion of the x-scaling or y-scaling. The viewportTransform() function provides a flip argument to allow this sort of transformation to be specified. The following code demonstrates this argument by first defining a group based on a piece of text in the bottom-left of a bottom-left justified viewport (in the bottom-left corner of the image). The group is then used in a taller viewport that is top-left justified, with flipY=TRUE. The resulting text is twice as high and inverted vertically. The group is used in two further viewports to show inversion of the x-scaling (bottom-right) and inversion of both scales (top-right).

pushViewport(viewport(.05, .05, just=c("left", "bottom"),
                      width=.2, height=.2))
grid.rect(gp=gpar(lty="dotted"))
grid.define(textGrob("hello", 0, 0, just=c("left", "bottom")),
            name="t")
grid.use("t")
popViewport()
pushViewport(viewport(.05, .95, just=c("left", "top"),
                      width=.2, height=.4))
grid.rect(gp=gpar(lty="dotted"))
grid.use("t",
         trans=function(...) viewportTransform(...,
                                               flip=groupFlip(flipY=TRUE)))
popViewport()
pushViewport(viewport(.95, .05, just=c("right", "bottom"),
                      width=.4, height=.2))
grid.rect(gp=gpar(lty="dotted"))
grid.use("t",
         trans=function(...) viewportTransform(...,
                                               flip=groupFlip(flipX=TRUE)))
popViewport()
pushViewport(viewport(.95, .95, just=c("right", "top"),
                      width=.4, height=.4))
grid.rect(gp=gpar(lty="dotted"))
grid.use("t",
         trans=function(...) viewportTransform(...,
                                               flip=groupFlip(flipX=TRUE,
                                                              flipY=TRUE)))
popViewport()
plot of chunk unnamed-chunk-37

It is also possible to specify a custom transformation in order to simplify the transformation that is induced by differences between viewports. For example, we can ensure that only translations occur and any differences in viewport size are ignored. The following code demonstrates this idea by first defining a group based on a rectangle in a small viewport (bottom-left). The group is then used in a larger viewport, but with viewportTranslate() as the transformation function. The resulting rectangle is the same size as the original rectangle because viewportTranslate() only takes any notice of differences in location between viewports; it ignores differences in size.

pushViewport(viewport(.25, .25, width=.2, height=.2))
grid.rect(gp=gpar(lty="dotted"))
grid.define(rectGrob(width=.2, height=.2, gp=gpar(fill="black")),
            name="r")
grid.use("r")
popViewport()
pushViewport(viewport(.75, .75, width=.4, height=.4))
grid.rect(gp=gpar(lty="dotted"))
grid.use("r",
         trans=viewportTranslate)
popViewport()
plot of chunk unnamed-chunk-38

It is also possible to transform a group without having to use it in a different viewport; we can just provide a transform argument to grid.use(). However, this sort of "raw" transformation takes place in the device coordinate system, which can make it difficult to determine the correct transformation. For example, the device origin (0, 0) is top-left for a Cairo device on screen and bottom-right is likely to be several hundred pixels in both dimensions.

The following code demonstrates how this might produce initially confusing results from naive code. We specify a transform function that calls groupRotate to generate an matrix that generates an anti-clockwise rotation of 30 degrees. However, the result is an anti-clockwise rotation of 30 degrees around the origin, where the origin is the top-left of the image.

grid.define(rectGrob(width=.4, height=.4,
                     gp=gpar(fill=rgb(0,0,0,.5))),
            name="r")
grid.use("r")
grid.use("r", transform=function(...) groupRotate(30))
plot of chunk unnamed-chunk-39

If we want to rotate the rectangle (in place), we need to translate to the device origin, rotate, and translate back. And in order to translate, we need to know the position of the rectangle on the device. The following code uses deviceLoc() to determine the correct location on the device and defines a transformation function that correctly rotates the rectangle.

grid.define(rectGrob(width=.4, height=.4,
                     gp=gpar(fill=rgb(0,0,0,.5))),
            name="r")
grid.use("r")
loc <- deviceLoc(unit(.5, "npc"), unit(.5, "npc"), device=TRUE)
trans <- function(...) {
    groupTranslate(-loc$x, -loc$y) %*%
    groupRotate(30) %*%
    groupTranslate(loc$x, loc$y)
}
grid.use("r", transform=trans)
plot of chunk unnamed-chunk-40

To further complicate matters, that transformation is only correct for when the transformation is called with device=TRUE (the custom transformation function above just ignores the device argument).

This demonstrates why it will usually be easier to specify the transformation through a change in viewport, as shown by the code below.

grid.define(rectGrob(width=.4, height=.4,
                     gp=gpar(fill=rgb(0,0,0,.5))),
            name="r")
grid.use("r")
pushViewport(viewport(angle=30))
grid.use("r")
plot of chunk unnamed-chunk-41

The vp argument

All of the functions grid.group(), grid.define(), and grid.use() have a vp argument. This allows us to specify a viewport that will be pushed before the group is drawn (or defined or used) and then navigated up out of afterwards.

For grid.group(), this is straightforward, the viewport is pushed, the group is defined and used, and then we come up out of the viewport. For example, the code below draws a group within a temporary viewport in the bottom half of the image.

vp <- viewport(y=0, height=.5, just="bottom")
grid.group(grobTree(rectGrob(), circleGrob()),
           vp=vp)
plot of chunk unnamed-chunk-43

Normally, when we use grid.define() and grid.use(), if we call them within the same viewport, we will draw the untransformed group. However, if we specify a viewport via the vp argument in the call to grid.define(), but not in grid.use(), the group definition will occur in a different viewport than the group use and the group will be transformed, as shown below (notice that the viewport for the group use is not only taller, but has a different x/y location compared to the viewport for the group definition because the viewport for the group definition was bottom-justified at y=0 while the viewport for the group use is centred at y=.5).

grid.define(grobTree(rectGrob(), circleGrob()),
            vp=vp,
            name="groupvp")
grid.use("groupvp")
plot of chunk unnamed-chunk-44

We can of course get the untransformed group by specifying the same viewport via vp in the grid.use() call, so that the group definition and the group use occur within the same viewport, as shown below.

grid.define(grobTree(rectGrob(), circleGrob()),
            vp=vp,
            name="groupvp")
grid.use("groupvp",
         vp=vp)
plot of chunk unnamed-chunk-45

Treating a plot as a group

This section returns to the more complex example from the Introduction Section. Now that we have seen how the new graphics features work, we can explain how to produce this plot. This example also demonstrates that groups can be based on any 'grid' grob, including a grob that represents an entire 'ggplot2' plot.

The plot that we will work with is defined with the following code.

library(ggplot2)
gg <- ggplot(mtcars) +
    geom_point(aes(disp, mpg)) +
    theme_bw() +
    theme(panel.background=element_rect(color=NA, fill="transparent"),
          plot.background=element_rect(color=NA, fill="transparent"))

The code below begins by pushing a viewport that is bottom-left justified at the bottom-left corner of the image (and only occupies 60% of the width of the image). We then capture the 'grid' gTree that would be drawn by this plot (and make all lines double thickness). We also define a group based on that gTree. Next, we define a transformation function that will add a shear in the x-direction to the default viewport transformation. We push a new viewport that is much shorter than the first viewport. This induces a scaling in the y-direction. This viewport also enforces a semitransparent mask. We use the group in this new viewport, specifying our custom transformation, which produces a semitransparent, vertically squashed, and horizontally skewed version of the plot. Finally, we pop the second viewport and draw the original group over the top of its squashed and skewed "shadow".

pushViewport(viewport(x=0, y=0, width=.6,
                      just=c("left", "bottom")))
g <- editGrob(grid.grabExpr(plot(gg), gp=gpar(lex=2)))
grid.define(g, name="g")
trans <- function(...) {
    viewportTransform(..., shear=groupShear(2, 0))
}
pushViewport(viewport(x=0, y=0, height=1/4, just=c("left", "bottom"),
                      mask=rectGrob(width=2,
                                    gp=gpar(col=NA, fill=rgb(0,0,0,.7)))))
grid.use("g", transform=trans)
popViewport()
grid.draw(g)
plot of chunk unnamed-chunk-47

Listing and editing groups

In simple situations, groups behave very much like a standard 'grid' gTree. A group consists of one or more grobs and when we draw the group we just draw that collection of grobs. However, previous sections have demonstrated that, by specifying different compositing operators, or by defining a group in one viewport and using it in another, we can get behaviour that deviates from standard gTrees. This section demonstrates that, even in simple situations, when a group produces the same graphical output as an equivalent gTree, there are some things that still work differently.

In order to demonstrate the differences, we will work with a red rectangle grob and a green circle grob.

r <- rectGrob(gp=gpar(col=NA, fill=2, lwd=3), name="r")
c <- circleGrob(r=.4, gp=gpar(col=NA, fill=3, lwd=3), name="c")

The following code creates a gTree with the rectangle and the circle as its children. We draw the gTree and then use grid.ls() to list the grobs in our image. The result shows that we have a gTree with a rectangle (r) and a circle (c) as its children.

gt <- gTree(children=gList(r, c), name="gTree")
grid.draw(gt)
plot of chunk unnamed-chunk-49
grid.ls()
  gTree
    r
    c

We can now use the grid.edit() function to modify the gTree or its children. In the code below we shrink the width of the rectangle.

grid.edit("r", width=unit(.5, "npc"))
plot of chunk unnamed-chunk-51

The following code creates a group equivalent of the gTree. In this group, the src is the circle and the dst is the rectangle (and the default operator is "over"), so when we draw the group, we get the same result as the gTree.

grid.group(src=c, dst=r, name="group")
plot of chunk unnamed-chunk-52

However, if we list the grobs in the image, all we can see is the group grob.

grid.ls()
  group

This demonstrates that the src and dst of a group are not the same as the children of a gTree. A gTree draws its children, but a group is defined by its children. This also means that we cannot directly access the grobs that define a group with a single gPath (like we can with the children of a gTree). However, it is still possible to modify the definition of a group, as shown in the code below.

grid.edit("group", dst=editGrob(r, width=unit(.5, "npc")))
plot of chunk unnamed-chunk-56
grid.edit("group", op="xor")
plot of chunk unnamed-chunk-58

Forcing groups

This section deals with more advanced 'grid' concepts and assumes a strong familiarity with 'grid'. It builds on the ideas of listing and editing individual grobs within an image, but deals with more complex scenarios than the previous section.

It is possible to create 'grid' grobs that generate their content when they are drawn. An example is the grob that a 'ggplot2' plot creates. For example, the following code defines a 'ggplot2' plot, draws it, and lists the grob that has been created.

g <- ggplot(mtcars) + geom_point(aes(disp, mpg))
plot(g)
plot of chunk unnamed-chunk-59
grid.ls()
  layout

The listing only shows a single grob with no children. This is a "gtable" grob that contains all of the information necessary to draw the 'ggplot2' plot, but it has not created all of the individual text and rectangle grobs for the plot yet; it does that every time it gets drawn.

If we want to access the individual text and rectangle grobs for the plot, we have to "force" the "gtable" grob. The following code does this and shows that there are now lots of individual grobs.

grid.force()
grid.ls()
plot of chunk unnamed-chunk-61
  layout
    background.1-9-12-1
    panel.7-5-7-5
      grill.gTree.460
        panel.background..rect.451
        panel.grid.minor.y..polyline.453
        panel.grid.minor.x..polyline.455
        panel.grid.major.y..polyline.457
        panel.grid.major.x..polyline.459
      NULL
      geom_point.points.447
      NULL
      panel.border..zeroGrob.448
    spacer.8-6-8-6
    spacer.8-4-8-4
    spacer.6-6-6-6
    spacer.6-4-6-4
    axis-t.6-5-6-5
    axis-l.7-4-7-4
      NULL
      axis
        axis.1-1-1-1
          GRID.text.467
        axis.1-2-1-2
    axis-r.7-6-7-6
    axis-b.8-5-8-5
      NULL
      axis
        axis.1-1-1-1
        axis.2-1-2-1
          GRID.text.463
    xlab-t.5-5-5-5
    xlab-b.9-5-9-5
      GRID.text.471
    ylab-l.7-3-7-3
      GRID.text.474
    ylab-r.7-7-7-7
    subtitle.4-5-4-5
    title.3-5-3-5
    caption.10-5-10-5
    tag.2-2-2-2

The next code modifies the individual points grob to colour the points alternating red and green

grid.edit("points", grep=TRUE, gp=gpar(col=2:3))
plot of chunk unnamed-chunk-63

It is possible to create a group based on a grob that only generates its content when it is drawn. For example, the following code creates a group based on the 'ggplot2' plot from above.

ggrob <- ggplotGrob(g)
gp <- groupGrob(ggrob, name="group")

As the previous section pointed out, grid.ls() and grid.edit() cannot see or access the src or dst of a group grob. For the group that we just defined, when we look at the group src, we cannot see any of its individual grobs either; the src is just a "gtable" grob that will generate its individual grobs when it gets drawn. This is illustrated in the output from grid.ls() below.

grid.ls(gp)
  group
grid.ls(gp$src)
  layout

In this situation it is doubly hard to access and modify the grobs within a group. However, it is still possible if we edit the grob before we use it to define a group. For example, the following code forces the 'ggplot2' plot grob and edits it before defining a group based on the edited grob. The result is a group based on the modified 'ggplot2' plot.

ggrobForced <- forceGrob(ggrob)
ggrobMod <- editGrob(ggrobForced, "points", grep=TRUE,
                     gp=gpar(col=2:3))
grid.group(ggrobMod)
plot of chunk unnamed-chunk-66

A slightly more difficult scenario arises when we want to edit the individual grobs within a group that someone else's code has drawn. In this case, we need to access the group, access the src grob within the group, force that src grob, modify the forced grob, and edit the group to replace the original src with the new forced and modified version. The following code demonstrates this idea, starting with a group that is drawn from a 'ggplot2' "gtable" grob; this represents a group that has already been drawn based on a src grob that generates its individual grobs only when it is drawn.

grid.group(ggrob, name="group")
gp <- grid.get("group")
src <- gp$src
srcForced <- forceGrob(src)
srcMod <- editGrob(srcForced, "points", grep=TRUE,
                   gp=gpar(col=2:3))
grid.edit("group", src=srcMod)
plot of chunk unnamed-chunk-67

4. Device API

This section describes the impact that the changes to R will have on packages that provide a graphics device, like the 'ragg' package (Pedersen and Shemanarev, 2021).

The good news is that maintainers of R packages that implement graphics devices do not need to do anything in response to these changes. Graphics device packages will need to be reinstalled for R version 4.2.0, but they do not need to be updated. The graphics engine will only make calls to the graphics device to define or use groups if the graphics device deviceVersion is set to 15 (R_GE_group) or higher. Of course, if the graphics device has a lower deviceVersion, R code that defines or uses groups will have no effect.

A template for no support

A device can be updated, by setting dev->deviceVersion to 15 (R_GE_groups), but it does not have to offer support for the new features.

As an example of the (minimal) changes necessary to update a device (without support for any of the new group features), the following diff output shows the changes made to the postscript() device.

@@ -3033,6 +3033,14 @@
+static SEXP     PS_defineGroup(SEXP source, int op, SEXP destination,
+                               pDevDesc dd);
+static void     PS_useGroup(SEXP ref, SEXP trans, pDevDesc dd);
+static void     PS_releaseGroup(SEXP ref, pDevDesc dd);

@@ -3495,11 +3503,17 @@
+    dd->defineGroup     = PS_defineGroup;
+    dd->useGroup        = PS_useGroup;
+    dd->releaseGroup    = PS_releaseGroup;

-    dd->deviceVersion = R_GE_definitions;
+    dd->deviceVersion = R_GE_group;

@@ -4535,8 +4549,22 @@
+static SEXP PS_defineGroup(SEXP source, int op, SEXP destination, pDevDesc dd) {
+    return R_NilValue;
+}

+static void PS_useGroup(SEXP ref, SEXP trans, pDevDesc dd) {}

+static void PS_releaseGroup(SEXP ref, pDevDesc dd) {}
  

Implementing support for groups

This section provides information about what to do if a graphics device package wishes to provide support for the new group features.

The dev->deviceVersion must be set to 15 (R_GE_groups) or higher.

A device must implement the new dev->defineGroup function. The first and third arguments to this function (source and destination) are R function objects, with the destination potentially NULL. If destination is not NULL, the device should evaluate the destination function. As with clipping paths and masks (Murrell, 2020b), this will generate further calls to the device to draw shapes, which the device should capture to define the "destination" (rather than drawing immediately). For example, the Cairo devices perform the drawing within a cairo_push_group and cairo_pop_group; the pdf() device records the drawing within temporary strings.

The device should next set the compositing operator based on the second argument to dev->defineGroup, called op. This should happen regardless of whether destination is NULL or not. The C API provides R_GE_compositeClear, R_GE_compositeSource, R_GE_compositeOver, etc to switch on.

The device should then evaluate the source function and capture the resulting drawing as the "source". This combination of destination (maybe) combined with source using the given compositing operator defines the group. The device should return a reference to the resulting group definition. This reference can be any R object; it only has to make sense to the device.

The device must enforce the current graphical parameter settings when creating the group definition, so that these are fixed for when the group is used.

A device must also implement the new dev->useGroup. The first argument is a reference to a group definition (taken from the return value of a call to dev->defineGroup). The second argument is a 3x3 R matrix object that specifies an affine transformation. The device should apply the transformation and combine the group with any previous drawing using the normal "over" operator.

Finally, the device should implement dev->releaseGroup. The first argument to this function is a reference to an existing group. This allows the device to release resources associated with a group.

Exemplars

Support for these new features has been implemented for the pdf() device and the devices that are based on Cairo graphics, so the code for those devices demonstrates some possible approaches to implementation. In both of these cases, the infrastructure that was previously added to support fill patterns, clipping paths, and masks has been reused (Murrell, 2020b).

For the pdf() device, defineGroup creates temporary strings to capture PDF code during the evaluation of the source and destination functions. These, along with the op are used to define the content stream of an XObject that can be referenced elsewhere in the PDF document. An integer index is returned as the result. The useGroup implementation sets up the appropriate transformation matrix and performs a Do operation with a reference to the relevant XObject. The releaseGroup implementation does nothing.

For Cairo devices, defineGroup uses cairo_push_group() to capture Cairo drawing operations as a separate image. Any drawing during the evaluation of source and destination is captured to that image, with the op compositing operator set between evaluating destination and source. The image is held in memory until the group is released. Again, an integer index to the group is returned as the result. The useGroup implementation sets up an appropriate transformation matrix and then uses the relevant group image to paint the group on the main image. The releaseGroup implementation releases the group image from memory.

5. Discussion

The addition of groups to the R graphics engine significantly extends the range of graphical effects that can be achieved with R code. As mentioned in the introduction, the primary motivation for these changes is to provide access to more sophisticated graphical features in graphics formats like PDF and graphics systems like Cairo, so that more can be achieved purely in R code rather than having to fine-tune images manually outside of R.

Limitations

The most important limitation to acknowledge is the fact that these new features are only currently supported on a subset of the core graphics devices: the pdf() device and the devices based on Cairo graphics (e.g., png(type="cairo"), cairo_pdf() and svg()). Furthermore, the Porter-Duff compositing operators are only supported on the Cairo devices.

The 'ragg' package is adding support for the previous set of graphics features (gradients, patterns, clipping paths, and masks), so there is hope that the 'ragg' package will also be able to support this new set of features as well in the future.

Although the user interface for the new features is limited to the low-level 'grid' graphics system, any high-level graphics system that is built on 'grid', like 'ggplot2', can also make use of the new features, either by post-hoc editing with functions like grid.edit(), or, in the case of 'ggplot2', by adding layers with the 'gggrid' package (Murrell, 2021b). Thanks to the 'gridGraphics' package (Murrell and Wen, 2020), it is also possible to work with plots from the 'graphics' package by converting them to 'grid' equivalents and then using post-hoc editing.

Related work

The addition of these new features broadens the range of graphical output that is possible with R graphics. This narrows the gap between R graphics and some other graphics systems like PGF/TikZ (Tantau, 2021). For example, the ability to apply affine transformations is similar to the "canvas" coordinate system within PGF. On one hand this allows users to generate graphical images within a single system rather than having to mix systems in order to get specific features. On the other hand, this improves our ability to exchange output between systems without losing features. For example, the 'dvir' package (Murrell, 2021a; Murrell, 2020a) should be able to correctly import and render a wider range of TikZ output.

Similarly, the packages 'grImport' (Murrell, 2009) and 'grImport2' (Potter and Murrell, 2019) which import PostScript and SVG images to R should be able to import and correctly reproduce a wider range of external images.

Several other packages provide ways to expand the range of graphical output in R, many of them as extensions to the 'ggplot2' system. For example, 'ggpattern' (FC, 2020) provides functions for generating pattern fills and 'ggtext' (Wilke, 2020) provides more complex text formatting. The 'ggfx' package (Pedersen, 2021) provides, at a raster level, compositing operators, plus a whole range of other filters. There is also some overlap with the 'gridGeometry' package (Murrell, 2019), which allows shapes to be combined, e.g., with an "xor" operator, before rendering the result. In all of these cases, the additional features within the R graphics engine should make it easier for those packages to do their work (at least on some graphics devices). Because they do not rely on graphics device support, the enduring usefulness of these packages will be their ability to work across a wider range of R graphic devices. On the other hand, some of the new features, for example affine transformations, are not currently available any other way in R graphics.

Acknowledgements

Thanks to the CRAN group, particularly Brian Ripley, for assistance with testing and coordinating the merge of these changes into R.

Thanks to Trevor Davis for early testing that lead to important fixes in the handling of transformations induced by grid.use().

6. Technical requirements

The examples and discussion in this report relate to R version 4.2.0 (except for a couple of examples in the Appendix which require the daily R-release snapshot from 2022-05-25, which will be part of R version 4.2.1).

This report was generated within a Docker container (see Resources section below).

7. Resources

How to cite this report

Murrell, P. (2021). "Groups, Compositing Operators, and Affine Transformations in R Graphics" Technical Report 2021-02, Version 2, Department of Statistics, The University of Auckland. Version 1. [ bib | DOI | http ]

8. References

[Adobe Systems Incorporated, 2001]
Adobe Systems Incorporated (2001). PDF Reference: Adobe portable document format version 1.4, 3rd edition. [ bib | .pdf ]
[FC, 2020]
FC, M. (2020). ggpattern: Geoms with Patterns. R package version 0.2.0. [ bib | http ]
[Murrell, 2009]
Murrell, P. (2009). Importing vector graphics: The grImport package for R. Journal of Statistical Software, 30(4):1--37. [ bib | http ]
[Murrell, 2019]
Murrell, P. (2019). gridGeometry: Polygon Geometry in 'grid'. R package version 0.2-0. [ bib | http ]
[Murrell, 2020a]
Murrell, P. (2020a). Adding TikZ support to 'dvir'. Technical Report 2020-05, Department of Statistics, The University of Auckland. version 1. [ bib | DOI | http ]
[Murrell, 2020b]
Murrell, P. (2020b). Catching up with R graphics. Technical Report 2020-04, Department of Statistics, The University of Auckland. version 1. [ bib | DOI | http ]
[Murrell, 2021a]
Murrell, P. (2021a). dvir: Render DVI Files. R package version 0.3-2. [ bib | http ]
[Murrell, 2021b]
Murrell, P. (2021b). gggrid: Draw with 'grid' in 'ggplot2'. R package version 0.1-0. [ bib | http ]
[Murrell and Wen, 2020]
Murrell, P. and Wen, Z. (2020). gridGraphics: Redraw Base Graphics Using 'grid' Graphics. R package version 0.5-1. [ bib | http ]
[Packard et al., 2021]
Packard, K., Worth, C., and Esfahbod, B. (2021). Cairo graphics library. https://www.cairographics.org/. Accessed: 2021-10-24. [ bib ]
[Pedersen, 2021]
Pedersen, T. L. (2021). ggfx: Pixel Filters for 'ggplot2' and 'grid'. R package version 1.0.0. [ bib | http ]
[Pedersen and Shemanarev, 2021]
Pedersen, T. L. and Shemanarev, M. (2021). ragg: Graphic Devices Based on AGG. R package version 1.1.3. [ bib | http ]
[Porter and Duff, 1984]
Porter, T. and Duff, T. (1984). Compositing digital images. In Proceedings of the 11th Annual Conference on Computer Graphics and Interactive Techniques, SIGGRAPH '84, pages 253--259, New York, NY, USA. Association for Computing Machinery. [ bib | DOI | http ]
[Potter and Murrell, 2019]
Potter, S. and Murrell, P. (2019). grImport2: Importing 'SVG' Graphics. R package version 0.2-1. [ bib | http ]
[R Core Team, 2019]
R Core Team (2019). R: A Language and Environment for Statistical Computing. R Foundation for Statistical Computing, Vienna, Austria. [ bib | http ]
[Tantau, 2021]
Tantau, T. (2021). The TikZ and PGF Packages. [ bib | http ]
[Wickham, 2016]
Wickham, H. (2016). ggplot2: Elegant Graphics for Data Analysis. Springer-Verlag New York. [ bib | http ]
[Wilke, 2020]
Wilke, C. O. (2020). ggtext: Improved Text Rendering Support for 'ggplot2'. R package version 0.1.1. [ bib | http ]

9. Appendix: Cairo Graphics Compositing Operators

This section records some explanations of the behaviour of compositing operators on Cairo-based graphics devices. The main takeaway is to be careful when using a group as the source for a "clear" or "source" compositing operation. The information in this section explains why.

The Cairo Graphics model draws the current "source" onto the current "surface", with the current "mask" determining where on the surface the source is drawn, and the current "compositing operator" determining how the source is combined with the surface.

For example, by default, there is no mask, and the compositing operator is "over", so the source is drawn on top of whatever is already on the surface. If the source is semitransparent, the source may not completely obscure what is already on the surface.

There are three ways to draw, each of which specifies a different sort of mask:

  1. We can create a "path", like a line or a circle or a polygon, and then stroke or fill the path. In this case, the mask is based on the path; the mask only allows the source to be drawn on the border of the path when stroking or within the interior of the path when filling.

    This is what happens most of the time when we draw with 'grid'. For example, a call to grid.circle() creates a circular path and strokes that path (in black). The current source is "black" everywhere and the mask is the border of the circle.

  2. We can "mask" the source. This means that we specify an explicit mask (based on drawing on another surface) and the opaque regions of that mask determine where the source is drawn on the surface.

    This is what happens whenever we have specified a mask in 'grid', e.g., by calling pushViewport(viewport(mask = segmentsGrob())) and then calling grid.circle(). The segments grob is drawn on another surface to create the mask and then only the parts of the circle that overlap with the segments are drawn on the main surface.

  3. We can "paint" the source. This means that there is no mask, so the source is drawn over the entire surface.

    This is what happens whenever we draw a group with 'grid' (as described in this document). The group is drawn on another surface and then it is copied, in its entirety, onto the main surface.

There are many different compositing operators, but in almost all cases when we draw with 'grid' we are using the "over" operator; the source is drawn on top of whatever is currently on the surface.

We can only make use of operators other than "over" within a group, so the situation we are considering here is drawing a path, or with a mask, or as a group, within a group using the grid.group() function. With this function, the src argument provides the source, the op argument specifies the compositing operator, and the dst argument specifies the surface that we are drawing onto.

For most compositing operators, the three ways to draw produce the same result. For example, the following output shows the results for the "over" and "xor" operators.

In each example below, the source is a red circle and the surface contains a green circle. On the top row, the source is just a circle grob, so the source is a (filled) "path" and the mask is just the circle. On the middle row, we draw the same circle grob, but this time within a viewport with a mask (based on the circle grob), so we "mask" the source and the mask is just the circle. On the bottom row, we draw the same circle grob, but this time as a group, so we "paint" the source and the source is the a transparent region the same size as the surface, with a red circle on it (there is no mask).

In the left column below, the operator is "over", so the red circle partially obscures the green circle. In the right column, the operator is "xor", so the region where the red and green circles overlap is not drawn.

src <- circleGrob(3/5, 2/5, r=.2, gp=gpar(col=NA, fill=2))
srcMasked <- editGrob(src, vp=viewport(mask=src))
srcGrouped <- groupGrob(src)
dst <- circleGrob(2/5, 2/5, r=.2, gp=gpar(col=NA, fill=3))
plot of chunk unnamed-chunk-70

The "clear" and "source" operators are special because they produce different results for different types of drawing. The output below shows the same three drawing scenarios on the three rows, but with the "clear" and "source" operators in the columns. In the top two rows, the mask is just the red circle. For the "clear" operator, the green circle is "clear"ed where the red circle overlaps it (the red circle is not drawn itself). For the "source" operator, the red circle replaces anything on the surface below where the red circle is drawn, including where it overlaps the green circle. (The result in this case is the same as the "over" operator, but it would be different if the red circle was semitransparent.)

The bottom row, which represents "paint"ing the source, is different from the top two rows because the source is the same size as the surface (there is no mask). With operator "clear", the green circle is "clear"ed completely because the source clears the entire surface. With operator "source", the green circle is replaced completely because the source replaces the entire surface.

plot of chunk unnamed-chunk-71

We can get the same result from "paint"ing the source as we do from a "path" source or a "mask"ed source if we limit the area that the "paint" option draws, either by applying a mask (in which case we change to "mask"ing the source) or by setting a clipping region. In the output below, the top row repeats the "paint"ed source from the previous output and the source either "clear"s the green circle entirely or the source replaces the green circle entirely.

In the second row, the source is a group with a viewport that imposes a mask (based on the red circle), so the source is "mask"ed rather than "paint"ed and the source only clears or replaces the green circle where the red circle overlaps it.

In the third row, the source is a group with a viewport that sets a clipping path (based on the red circle), so the source is still "paint"ed, but the drawing is limited to just the clipping region, so the green circle is only cleared or replaced where the red circle overlaps it.

srcGroupMasked <- editGrob(srcGrouped, vp=viewport(mask=src))
srcGroupClipped <- editGrob(srcGrouped, vp=viewport(clip=src))
plot of chunk unnamed-chunk-74

Unfortunately, the "mask"ed output above relies on a fix that did not quite make the R 4.2.0 release, but the fix should be available in R 4.2.1.


Creative Commons License
This document by Paul Murrell is licensed under a Creative Commons Attribution 4.0 International License.