How to build a custom R graphics device

Did I need to write a graphics device to output plots to an HTML canvas? No I did not. Did anyone ask for this? Oh hells no. Ah, but did I have fun? Hahahahahahahaha. No
R
C
Javascript
Author
Published

October 18, 2026

For the longest time, graphics devices in R have been a mystery to me. Yes, I know a lot about how to create graphics in R: I can create some rather lovely data visualisations with ggplot2, I can write grid code directly, and I can use base R graphics when I need to. But this code operates at a level of abstraction one step above the creation of the image itself: in all cases, what these tools do is send instructions to an R graphics device. This device is the thing responsible for taking the R code and transforming it into a graphic of some kind. Depending on what kind of device you’re using, this output could be an on-screen image, a png file, a jpeg, an svg file, or any of a number of other possibilities.

Since my very earliest days of using R I have always wondered what in the everliving fuck actually happens at this step. Graphics devices are rather opaque and mysterious entities, in large part because you can’t write them in R: all the work is done in C, with a thin R wrapper laid over the top. There is of course documentation for this but, well, it’s not easy for a novice to dive into. And so it inevitably came to pass that my frustration over my own incompetence spilled over, and I decided to use the master’s tools to build my own goddamn house: I shamelessly used Claude to teach me how these things are constructed, so that I could do it myself if I bloody well wanted to.1

This is that story.

(R code)
library(cnvs)
library(ggplot2)



The cnvs package

The initial brief I gave to Claude was explicitly pedagogical. I initialised the djnavarro/cnvs repository as an empty R package and then gave it the following task:

I’d like to create a personal package that I can use to learn how R graphics devices work. The goal here is to implement an R graphics device that sends output to an HTML canvas object, similar to how the “canvas” package does or how the webr::canvas() function solves the same task, by having the R graphics device emit the commands required to draw the output using the JavaScript 2D context API. As noted the primary goal is pedagogical, so it is important that the code be well-commented and interpretable to someone who does not already understand the structure of an R graphics device

I chose this as my learning example because I know a fair bit of HTML, CSS, and javascript already, and because I’ve hand-coded my own <canvas> objects in HTML/JS I knew that the end result would be a document that would be comprehensible to me. I know just enough C that I knew I’d be able to read the C code itself (albeit perhaps needing a little refresher), and I know how to write an R package. The “learning gap” that I wanted Claude to help me fill relates specifically to the R graphics device itself.2

When given those instructions, Claude did its usual thing… it burned a bucketload of tokens thinking about the problem, wrote out a plan, and then coded a first-pass version of the package. As is usually the case with LLMs it worked very quickly, so much so that I couldn’t follow it in real time. I had to wait until it completed its first attempt. I read through the code, played around with the package, and asked it a lot of questions that would probably have been suuuuuper-annoying if I’d asked a human. Some of my questions revealed bugs in the code that the LLM had written, others unearthed some poor design choices that made the code harder to read than it needed to be, and so on. It felt slightly gratifying to me that even in the early stages of my learning I was able to ask the kinds of questions that improved the code base, but I’m under no illusions about it: I didn’t write very much of the code myself, I asked an agent to do it and then tried to learn from the experience. In any case, the outcome of this process was the cnvs package itself, which supplies a simple and heavily-documented graphics device whose code I could dive into.

To give you a sense of what the resulting device does, here’s what the vanilla usage of the cnvs::cnvs() function looks like. Just like any other graphics device, we open the device, execute the plotting commands, and then close the device:

(R code)
cnvs(file = "mpg.html")
ggplot(mpg, aes(displ, hwy)) + geom_point()
dev.off()

Instead of printing the plot to an on-screen device or writing it to a PNG file via the png() device, the cnvs() device writes a self-contained HTML file, mpg.html in which the plot is written into an HTML canvas, complete with all the javascript code required so that the browser itself can take care of the drawing. If you follow the link to the mpg.html document and inspect the source, you can see the HTML/JS code for yourself. Or, alternatively, I could do that from this blog post and save you the effort:

(R code)
cat(readLines("mpg.html")[8:18], sep = "\n")
<canvas id="cnvs-page-1" width="480" height="480" style="border: none; margin-bottom: 1em;"></canvas>
<script>
(function() {
  var canvas = document.getElementById("cnvs-page-1");
  var ctx = canvas.getContext("2d");
  ctx.fillStyle = "rgba(255, 255, 255, 1.000)";
  ctx.fillRect(0, 0, 480, 480);
  ctx.save();
  ctx.beginPath();
  ctx.rect(0.00, 0.00, 480.00, 480.00);
  ctx.clip();

That’s terribly nice, and indeed the entire goddamn purpose of the tool, but as a convenience for myself the package also has a cnvs_embed() function that lets me embed the resulting canvas object directly into my quarto output:

(R code)
plt <- ggplot(mpg, aes(displ, hwy)) + geom_point()
cnv <- cnvs_embed(print(plt), width = 600, height = 600, bg = "#00000000")
cnv

Under the hood, calling cnvs_embed() enacts the same process: the ggplot object is printed to a cnvs() device, which writes it to a temporary file that contains the canvas element (omitting the boilerplate code needed to turn it into a standalone HTML file), reads it back into memory and then returns it to R as cnv, a “knitr-asis” object. So when my quarto blog post is rendered, the knitr engine passes the HTML/JS code in the cnv object directly into my document.3 The plot shown above isn’t rendered to PNG, it is a pure HTML/JS canvas object. The ggplot output shown above isn’t an image file, it’s an HTML canvas.

Neat.



Okay but… how???

So now we have this package, and it does the thing I wanted it to, but… how does it work? It’s a cute piece of functionality, but the truth is that the R ecosystem already has validated tools that do this. The real purpose of spending a few dollars to get Claude to write this thing for me is so that I have a simple and concrete example of an R graphics device, built from scratch with none of those tedious “how does it perform in production” considerations that might lead to complicated code and make it hard for me to understand. What precisely does my fancy new cnvs() graphics device do?? Wrapping my head around this is the actual purpose of the exercise.

Let’s get started. The first step in unpacking this is to look at the R code in the cnvs package, because that reveals the fundamental property that makes R graphics devices mysterious. Here’s the entire source for the cnvs() function:

cnvs.R (R code)
cnvs <- function(file = "cnvs.html", 
                 width = 480, 
                 height = 480,
                 pointsize = 12, 
                 bg = "white",
                 standalone = TRUE,
                 id_prefix = "cnvs-page") {
  stopifnot(
    is.character(file), length(file) == 1, !is.na(file),
    is.numeric(width), length(width) == 1, width > 0,
    is.numeric(height), length(height) == 1, height > 0,
    is.numeric(pointsize), length(pointsize) == 1, pointsize > 0,
    is.logical(standalone), length(standalone) == 1, !is.na(standalone),
    is.character(id_prefix), length(id_prefix) == 1, !is.na(id_prefix),
    nzchar(id_prefix)
  )

  # The real work happens in C, in src/device.c: this call builds the
  # device description struct and registers it with R's graphics engine.
  # Everything after this point -- every line(), rect(), text() call that
  # happens before dev.off() -- is routed by R straight to our C callbacks,
  # never back through this R function.
  invisible(.Call(
    "cnvs_device_",
    file,
    as.integer(width),
    as.integer(height),
    as.double(pointsize),
    bg,
    standalone,
    id_prefix
  ))
}

Lol. Lmao. Etc. This tells us nothing at all except that the graphics device doesn’t exist anywhere within the R code. The cnvs() function is nothing more than a wrapper around something else, and that something else only exists in C code. The moment the user calls cnvs(), everything gets delegated to the C function cnvs_device_. The R code itself doesn’t do anything besides that. In other words: an R graphics device is a tiny library written in C. So if we want to understand the graphics device, we need to start a slightly unnerving dive into the R internals and understand what the C code for a graphics device is expected to do…

Code
(R code)
# Some artwork to generate separator images, why not? The larger 
# separators in this blog post are created using this tiny
# generative art system. It's designed to create pieces that 
# look a little bit like hand-drawn sketches of multilayer 
# printed circuitry boards, which seems vaguely on-topic for a 
# blog post about graphics devices. They're not terribly 
# realistic though :)

# Palette generator
sample_palette <- function(seed = NULL) {

  palettes <- list(
    c("#de9151", "#f34213", "#2e2e3a", "#bc5d2e", "#bbb8b2"),
    c("#a63446", "#fbfef9", "#0c6291", "#000004", "#7e1946"),
    c("#ffffff", "#ffcad4", "#b0d0d3", "#c08497", "#f7af9d"),
    c("#aa8f66", "#ed9b40", "#ffeedb", "#61c9a8", "#ba3b46"),
    c("#241023", "#6b0504", "#a3320b", "#d5e68d", "#47a025"),
    c("#64113f", "#de4d86", "#f29ca3", "#f7cacd", "#84e6f8"),
    c("#660000", "#990033", "#5f021f", "#8c001a", "#ff9000"),
    c("#c9cba3", "#ffe1a8", "#e26d5c", "#723d46", "#472d30"),
    c("#0e7c7b", "#17bebb", "#d4f4dd", "#d62246", "#4b1d3f"),
    c("#0a0908", "#49111c", "#f2f4f3", "#a9927d", "#5e503f"),
    c("#020202", "#0d324d", "#7f5a83", "#a188a6", "#9da2ab"),
    c("#c2c1c2", "#42213d", "#683257", "#bd4089", "#f51aa4"),
    c("#820263", "#d90368", "#eadeda", "#2e294e", "#ffd400"),
    c("#f4e409", "#eeba0b", "#c36f09", "#a63c06", "#710000"),
    c("#d9d0de", "#bc8da0", "#a04668", "#ab4967", "#0c1713"),
    c("#012622", "#003b36", "#ece5f0", "#e98a15", "#59114d"),
    c("#3c1518", "#69140e", "#a44200", "#d58936", "#fffb46"),
    c("#6e0d25", "#ffffb3", "#dcab6b", "#774e24", "#6a381f"),
    c("#bcabae", "#0f0f0f", "#2d2e2e", "#716969", "#fbfbfb"),
    c("#2b4162", "#385f71", "#f5f0f6", "#d7b377", "#8f754f")
  )

  if (!is.null(seed)) set.seed(seed)
  ind  <- sample(seq_along(palettes), 1) 
  base <- sample(palettes[[ind]])
  return(base)
}

# Data generator
sample_circuit_data <- function(seed = NULL, n, width, height){

  if(!is.null(seed)) set.seed(seed)
  dat <- tibble::tibble(
    x0 = runif(n, min = 0, max = width),
    y0 = runif(n, min = 0, max = height),
    x1 = runif(n, min = 0, max = width),
    y1 = runif(n, min = 0, max = height),
    shade = runif(n), 
    size = runif(n)
  )
  return(dat)
}

# Plot constructor
plot_circuit <- function(dat, pal, width, height) {

  dat_black <- dat |> dplyr::slice_sample(prop = .4)
  x_limit <- c(-20, width + 20)
  y_limit <- c(-40, height + 40)

  plt <- ggplot2::ggplot(
    data = dat,
    mapping = ggplot2::aes(
      xmin      = x0,
      ymin      = y0,
      xmax      = x1,
      ymax      = y1,
      fill      = shade,
      colour    = shade
    )) + 
    ggplot2::coord_equal(xlim = x_limit, ylim = y_limit) +
    ggplot2::scale_colour_gradientn(colours = pal) + 
    ggplot2::scale_fill_gradientn(colours = pal) + 
    ggsketch::geom_sketch_rect(
      data = dat |> dplyr::slice_sample(prop = .1),
      linewidth = .5, 
      show.legend = FALSE,
      alpha = .5,
    ) +
    ggsketch::geom_sketch_rect(
      linewidth = .5, 
      show.legend = FALSE,
      fill = NA
    ) +
    ggsketch::geom_sketch_point(
      mapping = ggplot2::aes(
        x = x0, 
        y = y0, 
        color = shade
      ),
      data = dat |> dplyr::slice_sample(prop = .8),
      show.legend = FALSE,
      size = 4
    ) +
    ggsketch::geom_sketch_point(
      mapping = ggplot2::aes(x = x1, y = y1),
      data = dat_black,
      show.legend = FALSE,
      color = "black",
      size = 4
    ) +
    ggsketch::geom_sketch_segment(
      mapping = ggplot2::aes(
        x = x1, 
        y = y1,
        xend = (1-size)*x1 + size*x0/2,
        yend = y1,
      ),
      data = dat_black |> dplyr::slice_sample(prop = .5),
      show.legend = FALSE,
      color = "black",
      linewidth = 1
    ) +
    ggsketch::geom_sketch_segment(
      mapping = ggplot2::aes(
        x = x1, 
        y = y1,
        xend = x1,
        yend = (1-size)*y1 + size*y0/2,
      ),
      data = dat_black |> dplyr::slice_sample(prop = .5),
      show.legend = FALSE,
      color = "black",
      linewidth = 1
    ) +
    ggplot2::theme_void()
  return(plt)
}

# Top-level art generator
circuit <- function(seed, n = 50, width = 746, height = 400) {
  dat <- sample_circuit_data(seed, n, width, height)
  pal <- sample_palette(seed)
  plt <- plot_circuit(dat, pal, width, height)
  return(plt)
}

# Call the art generator, and embed the image
# as an HTML canvas within the document
cnvs_embed(
  expr = print(circuit(seed = 234)),
  width = 746,
  height = 400, 
  bg = "#00000000"
)



RTFM, if you can find TFM

One of the standout features of the R ecosystem is that documentation is both prioritised and standardised, and has been from the beginning. You cannot get a package onto CRAN without supplying a manual, which is constructed from the various .Rd files that come with your package. Nowadays we rarely write the .Rd files directly: we write a roxygen2 skeleton that describes the documentation we want, and more often than not also supply a pkgdown site that puts that documentation online in a predictable standard format. It’s so standard that there is even a pkgdown site for my no-no-dear-god-you-should-not-be-using-this cnvs package.

Unfortunately, the ubiquity of user-facing R documentation makes it come as something of a shock to discover that the documentation for internal R features is a little less easy to access. The R internals manual is excellent for providing an overview, but often you need to look into the source code for R itself: the comments inside the source are often the place where the API is documented. For graphics devices, the three places I’ve found it most useful to do a bit of reading are these:

Compared to the documentation for a typical R package, these are all a step up in difficulty for several reasons, not least of which is that the task is now to understand a C interface rather than an R interface. And let’s be honest here… C programming sucks. It’s psychologically painful, visually unpleasant, and has exactly one redeeming feature: C is fast.4 Indeed, this is arguably one of the places where R really does need to delegate the work to a compiled language like C. The data structure that underpins a plot like the one I created above is very minimal, and the performance cost for doing that with interpreted code like R is not all that much of a concern. But translating it to a sequence of low level drawing instructions is… yeah, that is quite a bit more work. You don’t want that step to be handled by interpreted code. So it inevitably comes to pass that we are working in C for this exercise.

Fine.

Whatever.

See if I care.



What’s she building in there?

Because all the important work in defining an R graphics device is done in C, the place you should look when trying to understand even a simple graphics package like cnvs is the src folder. That’s where all the C code lives. Thankfully, because I yelled at Claude a lot and told it how unbearably stupid I am when it comes to this particular topic, it did something terribly kind and structured the files nicely and commented them excessively. There are six files in this folder, and they each serve a distinct purpose. For example, there are two files that do a lot of the “background” work that is utterly critical to make things work, but don’t immediately help us conceptually:

  • cnvs.h. This is a header file that contains shared declarations used everywhere. It’s important in the sense that a lot of the code you see in the other files relies on these declarations and you need to read it to understand what those other files are up to, but it actually doesn’t do much of the work.

  • init.c. This one is bookkeeping. Any time an R package comes bundled with compiled code, it needs to tell R which bits of the C code should be considered callable from R. For that to work we need a function called R_init_cnvs that exposes that information, because R expects to find a function called R_init_<pkgname> that supplies this information. Fair enough, but in our case there’s only one: R needs to know that cnvs_device_ exists as an R-callable C function. If it doesn’t know that the user-facing cnvs() function won’t work. Yep, important, but also not central to understanding the graphics device itself.

Noting that those two files are in the background, and that – especially for cnvs.h – we might need to pivot back to them in order to understand what the other files are doing, we can turn our attention to the four files that do the substantive work of constructing a graphics device:

  • device.c. This file handles the interface between the cnvs graphics device and the R graphics engine. It is responsible for managing the lifecycle of a graphics device: whenever the R graphics engine asks it to do something like create a device, close a device, resize a device,5 create a new page, etc, it’s the code in this file that takes responsibility. It doesn’t do any of the drawing though.

  • draw.c. This file is where graphical primitives are handled. Any time that R needs to draw a line, a rectangle, a circle, a polygon, or even text, the code in this file handles that work. But it only does shapes. When you look at the code in this file, you’ll see that stylistic properties like colour and font get quietly farmed out to functions that live in style.c.

  • style.c. This is the file used for handling non-shape aspects of the graphics rendering: it is the file that handles colour conversions from R to javascript, but also handles the styling of lines, and the specification of fonts. All terribly handy things to have in a graphics device.6

  • write.c. This file is responsible for writing the output to an HTML file. It doesn’t know anything about the R graphics engine, and does not care. It exists to write the HTML document itself: every “page” in the R graphics sense of the term corresponds to a <canvas> and <script> pair in the HTML output. It’s the last step in the process.

Oddly, I find this scaffolding helpful in and of itself. I mean, I genuinely don’t understand the graphics device yet, but somehow seeing the structure of how you organise one of these things into a sensible set of files is informative. It tells me something about the specific tasks that go into building one of these things, and it also tells me where to look when I want to wrap my head around one of these parts. It also tells me that, if I want to start in a “safe” place and begin with the parts of the codebase that are likely to be least terrifying to me, I should begin with draw.c and style.c. I already know a lot of R and honestly more javascript than I really have any right to, so this part will probably make sense to me.

I am hopeful, then, that decrypting the strange inscriptions that Claude has provided for me in these files will not be as horrible a process as I fear it will be. Fingers crossed, I guess? But first, a little digression…

Code
(R code)
cnvs_embed(
  expr = print(circuit(seed = 102)),
  width = 746,
  height = 400, 
  bg = "#00000000"
)



The internal C interface

From a pedagogical standpoint, the place to go next is not to the C code in cnvs. Rather, the thing you want to do at this point is to learn a little bit about the internal interface for graphics devices supplied within the R source code. It’s not actually the thing I did, because it’s quite dense when you look at the real thing and required me to dredge my antiquated recollection of C back from the depths of my undergraduate memory. And to be honest, when I started trying to learn this stuff I didn’t even know what questions to ask, so it was only quite late in the process of detangling the cnvs source code that I started really trying to grasp it. Aaaanyway… I’ll spare you the pain I went through and show you the parts that are worth knowing about in advance. As the author of the blog post that seems like the polite thing for me to do ☺️

From the perspective of a would-be developer of an R graphics device, there are two key data structures defined in the R source code that you really need to know about:

  • The pGEcontext type supplies a pointer to a struct containing graphical parameters. Your graphics device never needs to define one of these itself, but it needs to be able to work with them when the R graphics engine tries to pass one of them to your device!
  • The pDevDesc type supplies a pointer to a struct that defines a graphical device. This is the more complicated and important one: all the heavy lifting is done via the device description, and (shockingly!) it turns out to be rather helpful if you understand what this thing actually is.

So okay, these two structures are important enough that they’re worth devoting some space to, even before we dive into the details of the specific device that cnvs defines. Let’s start with the easy one.


Graphical context

I’ll start by looking at pGEcontext first, defined in GraphicsEngine.h. It’s the simpler of the two, and provides a good pedagogical opportunity to talk about a few bits of C syntax that I once knew reasonably well but had forgotten because it’s been such a long time since I’ve needed to use this knowledge. The code below isn’t the exact source code, but it’s close enough for our purposes:

Adapted from "GraphicsEngine.h" (C code)
typedef struct {
    /* Colour */
    int col;                /* Pen colour (lines, text, borders, ...) */
    int fill;               /* Fill colour (polygons, circles, rects, ...) */
    double gamma;           /* Gamma correction */

    /* Lines */
    double lwd;             /* Line width (roughly number of pixels) */
    int lty;                /* Line type (solid, dashed, dotted, ...) */
    R_GE_lineend lend;      /* Line end */
    R_GE_linejoin ljoin;    /* Line join */
    double lmitre;          /* Line mitre */

    /* Text */
    double cex;             /* Character expansion (font size = fontsize*cex) */
    double ps;              /* Font size in points */
    double lineheight;      /* Line height (multiply by font size) */
    int fontface;           /* Font face (plain, italic, bold, ...) */
    char fontfamily[201];   /* Font family */

    /* Definitions */
    SEXP patternFill;       /* Reference to a pattern fill */
} R_GE_gcontext;

typedef R_GE_gcontext *pGEcontext;  /* Callbacks receive a pointer */

The first thing to note is the two-stage type definition: an R_GE_gcontext object is a struct (roughly7 analogous to a list in R) containing the various slots listed in the code extract above. In contrast, a pGEcontext object is a pointer to a struct of that type.

  • R_GE_gcontext is the struct itself: If your code contained an obj struct of this type, that object would have the slots obj.col, obj.fill, obj.gamma, and so on. In C syntax . is used as the accessor to a struct: it’s more like Python and Javascript in that respect, as they both use dots for that purpose. In R the closer analog is to $, the accessor that we use to extract an element of a list.

  • pGEcontext is the pointer: If our code contains a ptr pointer of this type it does not contain any slots, but we can access the slots of the struct to which it points by first dereferencing the pointer, so the code to extract the colour slot would be (*ptr).col. However, this code pattern is so common that C supplies the -> operator as shorthand and ptr->col is the same as (*ptr).col. It’s the latter form using -> that appears throughout the cnvs code. I swear to dog that I really did once know what -> means, but it still took me longer than I care to admit for it to come back to my mind.

In any case, once the “omg it’s been so long since I’ve written actual C code” tears dried up, this all made sense to me. In truth a graphical context pointer isn’t a super-complicated thing. All we need to do is remember that there are going to be places in our code when the R graphics engine passes one of these things to us, and we might want to use this information when generating output from our device. Cool. Let’s move on.


Device description

The other data structure you really need to understand is the device description. That’s the more important and more complicated one. Like pGEcontext the pDevDesc is a pointer type, but the struct to which it refers is quite a bit more elaborate. It is defined in the GraphicsDevice.h file, and I’m not going to show the whole thing here. Instead, here’s a heavily truncated version:

Adapted from "GraphicsDevice.h" (C code)
struct _DevDesc {
    /* Device physical characteristics */
    double left;            /* left raster coordinate */
    double right;           /* right raster coordinate */
    double bottom;          /* bottom raster coordinate */
    double top;             /* top raster coordinate */

    /* Device capabilities */
    Rboolean canClip;       /* Device-level clipping */

    /* Device initial settings -- the device must set these up when
     * created; the graphics system can modify them from there */
    int startcol;           /* sets par("fg"), par("col") and gpar("col") */
    int startfill;          /* sets par("bg") and gpar("fill") */

    /* Device specific information */
    void *deviceSpecific;   /* pointer to device specific parameters --
                             * this is where a device like cnvs stashes
                             * its own private struct (cnvs_state here) */

    /* Device display list */
    Rboolean displayListOn; /* toggle for initial display list status */

    /* Device procedures -- one function pointer per drawing/lifecycle
     * action the graphics engine might ask the device to perform. Two
     * examples: */
    void (*line)(double x1, double y1, double x2, double y2,
                 const pGEcontext gc, pDevDesc dd);
    void (*close)(pDevDesc dd);

    /* ... and many more fields and function pointers besides -- the real
     * struct also covers character-metric bookkeeping, starting graphical
     * parameters, event-handling capability flags, and a callback for
     * essentially every other drawable shape and lifecycle event (circle,
     * rect, polygon, text, newPage, activate, clip, locator, ...). */
};

typedef struct _DevDesc DevDesc;
typedef DevDesc* pDevDesc;

Once again we have the two-stage construction: a DevDesc object is a struct, and a pDevDesc object is a pointer to a struct of that type. For our graphics device code, it’s the pointers that get passed around, not the structs. You can see this in play in the function signature that we’ll need to implement if we want our graphics device to draw a line. The relevant snippet is this bit:

void (*line)(double x1, double y1, double x2, double y2,
             const pGEcontext gc, pDevDesc dd);

The arguments that the graphic engine will pass to our line-drawing function (when we actually get around to writing it!) will consist of four doubles and two pointers:

  • x1, y1, x2, and y2 are the numeric values that define the start and end points of the line
  • gc is a pointer to the graphical style parameters for the line (i.e., it’s a pGEcontext object)
  • dd is a pointer to the device description for our canvas device (i.e., it’s a pDevDesc object)

In short, the type definition for pDevDesc is the place to look if you want to find the moving parts of a graphics device. It tells you what slots it has to have, and what callback functions it needs to implement if you want the graphics device to function. The full version is rather long and annoying, and if I am perfectly honest I am still digesting the longer form of the code. But at least I now understand what I’m looking at, and that gives me the context that I need to make sense of the source code to cnvs.


Device specific data

One more thing to mention before I move on. Notice that a device description pointer like dd contains a slot allocated for dd->deviceSpecific. This is the place that the graphics engine sets aside so that the graphics device can define its own storage, in whatever form it needs. I’ll talk about this more explicitly later, but for now it’s sufficient to note that the cnvs package defines its very own cnvs_state type to place in the deviceSpecific slot, and uses it to store information like “what’s the name of the file I have to write this to anyway?????” Because this is a data structure specific to cnvs, the type definition exists within the cnvs.h header file, and here it is:

cnvs.h (C code)
typedef struct {
    FILE *fp;              /* the open .html file we are writing to */
    int width;             /* device width, in CSS pixels */
    int height;            /* device height, in CSS pixels */
    int page_count;        /* how many <canvas> pages we've started so far */
    Rboolean clip_active;  /* TRUE if we've done ctx.save()+ctx.clip() since
                            * the last matching ctx.restore() */
    Rboolean standalone;   /* TRUE (the default): write a complete, openable
                            * HTML document (<html>/<head>/<body> and all).
                            * FALSE: write only the <canvas>/<script> blocks
                            * for each page, with no surrounding document --
                            * meant to be read back and embedded as a
                            * fragment inside someone else's HTML (e.g. a
                            * knitted Quarto/R Markdown document), not
                            * opened directly in a browser. */
    char *id_prefix;       /* base string for each page's <canvas> id, e.g.
                            * "cnvs-page" produces ids "cnvs-page-1",
                            * "cnvs-page-2", ... Caller-supplied (rather than
                            * a fixed string) so that several cnvs devices
                            * writing fragments (standalone = FALSE) into
                            * the same host document -- e.g. several chunks
                            * of one Quarto/R Markdown file -- can be given
                            * distinct prefixes and never collide on
                            * `id="..."` in the final HTML. Owned by this
                            * struct: allocated in cnvs_device_(), freed in
                            * cnvs_Close(). */
} cnvs_state;

Because there will always be one of these objects wired up to the device description pointers that get passed around all over the place, the various drawing functions will have access to this information. Handy to have that lying around, really 🙃

In fact, it’s so handy – and it gets used so often – that when Claude wrote this type definition for cnvs_state it also added some convenience code into cnvs.h that simplifies our access to the device specific storage: we can use CNVS(dd) as a shorthand to pull out a pointer to the dd->deviceSpecific state information:

cnvs.h (C code)
#define CNVS(dd) ((cnvs_state *) (dd)->deviceSpecific)

Yep, makes sense to me (finally, after a great many tears were shed). Let’s move on, shall we?



Drawing shapes: draw.c

Okay. With all that as the background context for graphics devices, it’s time to dive into the C source within the cnvs package to see how it works. Because I like to do my learning in a sensible way, I decided to tackle the easy parts before I approach the terrifying things. Since I already know quite a bit about what the output should look like (i.e., some HTML/JS code for a canvas object), the least brain-breaking way for me to figure out what the fuck Claude just did was to inspect the draw.c file. That’s the place where my fancy new graphics device receives instructions from the R graphics engine about a shape it wants to draw, and then translates those into a set of instructions that javascript can apply when it writes to an HTML canvas. So that’s where I started.8

Code
(R code)
# Time to mix up the artwork a little bit. A minor variation
# of the "circuits" system that just draws some arcs. Not 
# very clever, admittedly, but it's not meant to do anything
# except provide a visual spacer between one subsection and
# the next

# Data generator
sample_arcs_data <- function(seed = NULL, n, width, height){

  if(!is.null(seed)) set.seed(seed)
  dat <- tibble::tibble(
    id = 1:n,
    x0 = runif(n, min = 50, max = width-50),
    y0 = runif(n, min = 50, max = height-50),
    r  = runif(n, min = 0, max = 50),
    a0 = runif(n, min = 0, max = pi * 2),
    a1 = runif(n, min = 0, max = pi * 2),
    shade = runif(n)
  ) |> 
    dplyr::reframe(
      .by = id,
      arc0 = min(a0, a1),
      arc1 = max(a0, a1), 
      x = x0 + cos(seq(arc0, arc1, .01)) * r,
      y = y0 + sin(seq(arc0, arc1, .01)) * r,
      shade = shade
    )
  return(dat)
}

# Plot constructor
plot_paths <- function(dat, pal, width, height) {

  x_limit <- c(-20, width + 20)
  y_limit <- c(-40, height + 40)

  plt <- ggplot2::ggplot(
    data = dat,
    mapping = ggplot2::aes(x, y, colour = shade),
  ) + 
    ggplot2::coord_equal(xlim = x_limit, ylim = y_limit) +
    ggplot2::scale_colour_gradientn(colours = pal) + 
    ggplot2::scale_fill_gradientn(colours = pal) + 
    ggsketch::geom_sketch_path(
      mapping = aes(group = id),
      linewidth = .5, 
      show.legend = FALSE
    ) +
    ggsketch::geom_sketch_point(
      data = dat |> dplyr::slice_sample(prop = .0025),
      show.legend = FALSE,
      size = 4
    ) +
    ggplot2::theme_void()
  return(plt)
}

# Top-level art generator
arcs <- function(seed, n = 20, width = 746, height = 150) {
  dat <- sample_arcs_data(seed, n, width, height)
  pal <- sample_palette(seed)
  plt <- plot_paths(dat, pal, width, height)
  return(plt)
}

cnvs_embed(
  expr = print(arcs(seed = 1101)),
  width = 746, height = 150, bg = "#00000000"
)


Drawing a line

Inside the draw.c script there is a function called cnvs_Line() that specifies what actions the graphics device should take when it is asked to draw a line. It takes the exact arguments that I described in the previous section (x1, y1, x2, y2 for the line end points, gc for the style, and dd for the device), and it translates those arguments into the javascript code needed to draw the corresponding line into the HTML canvas:

draw.c (C code)
void cnvs_Line(double x1, double y1, double x2, double y2,
               const pGEcontext gc, pDevDesc dd)
{
    /* pointer to the device-specific state */
    cnvs_state *state = CNVS(dd);

    /* nothing to draw if there's no stroke colour, or the line type
     * itself is invisible */
    if (gc->col == NA_INTEGER || gc->lty == LTY_BLANK) return;

    /* convert colour to CSS, and write the line's stroke style
     * (width, caps, joins, dashing) straight to the output file */
    char css[32];
    cnvs_colour_to_css(gc->col, css, sizeof(css));
    cnvs_set_line_style(state->fp, gc);

    /* write the JS that actually strokes the line itself */
    fprintf(state->fp, "  ctx.strokeStyle = \"%s\";\n", css);
    fprintf(state->fp, "  ctx.beginPath();\n");
    fprintf(state->fp, "  ctx.moveTo(%.2f, %.2f);\n", x1, y1);
    fprintf(state->fp, "  ctx.lineTo(%.2f, %.2f);\n", x2, y2);
    fprintf(state->fp, "  ctx.stroke();\n");
}

If you know a little bit about HTML canvas objects and the javascript framework used to draw content into them, the last five lines of this function make a lot of sense. From the perspective of the javascript engine that eventually has to render this code, ctx is the “context” object. That is, when we create an HTML <canvas> object, it is generally accompanied by some javascript wrapped in a <script> tag, and that javascript usually begins with a line of code that looks like this:

(javascript code)
var ctx = canvas.getContext("2d");

The ctx object is used by javascript to undertake all the drawing activities. So if we want to draw a line that starts at a location defined by x1 = 1 and y1 = 1 and terminates at a location defined by x2 = 2 and y2 = 2, then the javascript code we want looks like this:

(javascript code)
ctx.beginPath()
ctx.moveTo(1, 1)
ctx.lineTo(2, 2)
ctx.stroke()

So if we ignore the styling code for now – we’ll come back to that shortly – and just focus on the line-drawing behaviour, we can see that this is what the cnvs_Line() function exists for. The purpose of this callback is to write this text into the HTML file, so that the canvas will contain the correct description of the line that the R graphics engine has asked us to draw.

Code
(R code)
cnvs_embed(
  expr = print(arcs(seed = 1102)),
  width = 746, height = 150, bg = "#00000000"
)


Drawing a rectangle

Okay, we now know how to draw a line. What about rectangles? Within our graphics device, this task gets assigned to the cnvs_Rect() function, and the R graphics engine calls it with the following arguments:

  • x0, y0, x1 and y1 describe the opposite corners of the rectangle
  • gc supplies the style information associated with the rectangle
  • dd supplies the device description

The code is not terribly complicated:

draw.c (C code)
void cnvs_Rect(double x0, double y0, double x1, double y1,
               const pGEcontext gc, pDevDesc dd)
{
    /* pointer to the device-specific state */
    cnvs_state *state = CNVS(dd);

    /* (x0,y0) and (x1,y1) are opposite corners, but not guaranteed to
     * arrive as (top-left, bottom-right) -- normalise to canvas's
     * expected (x, y, width, height) form. */
    double left = (x0 < x1) ? x0 : x1;
    double top = (y0 < y1) ? y0 : y1;
    double w = fabs(x1 - x0);
    double h = fabs(y1 - y0);

    /* fill and stroke are independent -- a rectangle can have either, both,
     * or neither -- so each is checked and drawn separately, rather than
     * sharing one early-return guard the way cnvs_Line() does */
    if (gc->fill != NA_INTEGER && !R_TRANSPARENT(gc->fill)) {
        char css[32];
        cnvs_colour_to_css(gc->fill, css, sizeof(css));
        fprintf(state->fp, "  ctx.fillStyle = \"%s\";\n", css);
        fprintf(state->fp, "  ctx.fillRect(%.2f, %.2f, %.2f, %.2f);\n", left, top, w, h);
    }
    if (gc->col != NA_INTEGER && gc->lty != LTY_BLANK) {
        char css[32];
        cnvs_colour_to_css(gc->col, css, sizeof(css));
        cnvs_set_line_style(state->fp, gc);
        fprintf(state->fp, "  ctx.strokeStyle = \"%s\";\n", css);
        fprintf(state->fp, "  ctx.strokeRect(%.2f, %.2f, %.2f, %.2f);\n", left, top, w, h);
    }
}

The key thing to notice here is the thing noted in the comments. On the javascript side, drawing a filled rectangle is two separate actions: the ctx.fillRect() command provides the fill, and the ctx.strokeRect() command provides the stroke. Neither one is required, either on the R side (rectangles don’t have to have a fill or a line specification) or the javascript side, so it’s quite possible that this code does nothing whatsoever if there’s no fill or no stroke required.

Code
(R code)
cnvs_embed(
  expr = print(circuit(seed = 1003, n = 5, height = 150)),
  width = 746, height = 150, bg = "#00000000"
)


Drawing a circle

Let’s see a third example. The draw.c file also contains a function called cnvs_Circle(), which gets called whenever the graphics device is asked to draw a circle. We can see from looking at the code that it’s designed with a similar interface to the cnvs_Line() function from the last section. There are three numeric values x, y and r that define the circle itself, the gc pointer for style information, and the dd pointer for the device itself:

draw.c (C code)
void cnvs_Circle(double x, double y, double r,
                  const pGEcontext gc, pDevDesc dd)
{
    /* pointer to the device-specific state */
    cnvs_state *state = CNVS(dd);

    /* trace the circular path unconditionally -- ctx.arc() has to run
     * before either ctx.fill() or ctx.stroke() can act on it, so unlike
     * cnvs_Rect() there's no single colour/line-type check that would let
     * us skip this step entirely */
    fprintf(state->fp, "  ctx.beginPath();\n");
    fprintf(state->fp, "  ctx.arc(%.2f, %.2f, %.2f, 0, 2 * Math.PI);\n", x, y, r);

    /* fill, then stroke the same path -- same independence as
     * cnvs_Rect(): a circle can have either, both, or neither */
    if (gc->fill != NA_INTEGER && !R_TRANSPARENT(gc->fill)) {
        char css[32];
        cnvs_colour_to_css(gc->fill, css, sizeof(css));
        fprintf(state->fp, "  ctx.fillStyle = \"%s\";\n", css);
        fprintf(state->fp, "  ctx.fill();\n");
    }
    if (gc->col != NA_INTEGER && gc->lty != LTY_BLANK) {
        char css[32];
        cnvs_colour_to_css(gc->col, css, sizeof(css));
        cnvs_set_line_style(state->fp, gc);
        fprintf(state->fp, "  ctx.strokeStyle = \"%s\";\n", css);
        fprintf(state->fp, "  ctx.stroke();\n");
    }
}

As before, the core of this code is easy enough to follow if you know a little bit about the javascript framework used to draw onto an HTML canvas, and are willing to ignore the bits that are clearly related to something else. For instance, if we want to draw a unit circle centred on the origin, then our input arguments will be x = 0, y = 0, and r = 1, and the javascript we need to draw an unfilled circle would look something like this:

(javascript code)
ctx.beginPath()
ctx.arc(0, 0, 1, 0, 2 * Math.PI)
ctx.stroke()

As with the line and rectangle examples, I’m being a little sneaky by ignoring the ctx.strokeStyle() part of the output, because I haven’t started looking at the style.c file yet, but you get the idea. The cnvs_Circle() function has one job, and that job is to write the javascript specification for a circle. I mean… what else were we expecting, really?

Code
(R code)
cnvs_embed(
  expr = print(arcs(seed = 1105)),
  width = 746, height = 150, bg = "#00000000"
)


The draw.c overview

Now that we’ve seen three of the specific functions bundled in the draw.c file, we can now do the high-level overview without it being completely mysterious. It supplies the following functions, all of which have essentially the same structure as the two we’ve seen:

  • cnvs_Line(): draw a line
  • cnvs_Rect(): draw a rectangle
  • cnvs_Circle(): draw a circle
  • cnvs_Polyline(): draw multiple lines
  • cnvs_Polygon(): draw a polygon

In all cases the function takes some specific parameters defining the graphical object, along with the two all-important pointers gc and dd.

On top of these, there are three functions that are all related to drawing text into the output, which is invariably the most annoying part of rendering any graphic:

  • cnvs_Text()
  • cnvs_StrWidth()
  • cnvs_MetricInfo()

Text is inherently a pain in the arse, so I’m going to skip those in this post. For the time being it’s enough to note that the function signature for these is pretty similar to the others: there are arguments specific to “the thing to be drawn”, and arguments for the gc and dd objects. However, although text is kind of an awkward thing to work with, if you read the source code for these it’s not too difficult to grasp what they’re doing.

Finally, there’s a special function used to handle clipping (i.e., restricting the output to a specific region). There’s nothing novel or surprising in this one. It’s just like the other primitives, taking inputs from R and emitting the required javascript (i.e., ctx.clip()):

  • cnvs_Clip(): clip the output to a specified region

Big picture, then… we understand the structure of what draw.c is doing. Any time R requests that a shape be drawn (text counts as a shape), it gets routed to one of these functions, and the function in question writes the required javascript into the output file. Cool.

Code
(R code)
cnvs_embed(
  expr = print(circuit(seed = 654)),
  width = 746,
  height = 400, 
  bg = "#00000000"
)



Applying styles: style.c

Next, let’s look at the style.c file. It contains three functions, all of which are used to specify style information in a CSS format that the HTML/javascript code for the canvas object can understand:

  • cnvs_colour_to_css()
  • cnvs_set_line_style()
  • cnvs_set_font()

None of these are “front line” functions in the same sense that the draw.c functions are. As we’ll see later, all of those functions are callbacks that get wired directly into the R graphics device. In contrast, these three are all helper functions that get called by the various drawing functions inside draw.c. As I mentioned before, writing text into a graphical object sucks, so I’m going to ignore cnvs_set_font() for this post. But the other two we can look at now.


Specifying colour

Here’s the source code for cnvs_colour_to_css(). It’s not terribly complicated in itself, but requires a tiny bit of context. R packs a colour’s red, green, blue and alpha channels into a single integer, allocating one byte to each channel. That’s why the col argument is an integer, and why the code uses the R_RED(), R_GREEN(), R_BLUE() and R_ALPHA() functions to extract the values for each channel. The graphics device doesn’t have to define these functions, they’re supplied in R_ext/GraphicsDevice.h:

style.c (C code)
void cnvs_colour_to_css(int col, char *buf, size_t buf_size)
{
    int r = R_RED(col);
    int g = R_GREEN(col);
    int b = R_BLUE(col);
    double a = R_ALPHA(col) / 255.0;
    snprintf(buf, buf_size, "rgba(%d, %d, %d, %.3f)", r, g, b, a);
}

The first part of this function extracts r, g, b, and a in the format that the CSS rgba() function expects (i.e., red, blue and green are all integers between 0 and 255, whereas alpha is supplied as a value between 0 and 1). So the end result might be a string like "rgba(123, 45, 67, 0.890)" that is written into the buffer buf. If you scroll back up to the source code for the drawing functions I discussed in the last section, you can see that this generally turns out to be the css string that gets passed into the ctx.strokeStyle() and ctx.fillStyle() commands that are inserted in the javascript.


Specifying line style

The design of the cnvs_set_line_style() function is a little different. Unlike the cnvs_colour_to_css() function, which writes a CSS colour specification into a C string and then lets the drawing function itself take care of inserting it into the HTML canvas, the cnvs_set_line_style() function writes line style information directly into the canvas. Because of this, it takes two arguments:

  • A file pointer *fp, so that the function knows where to write
  • A graphical context pointer gc that contains the style information

There are several slots within the struct to which gc points that are relevant to the line style that we need to write into our HTML canvas. The table below shows the correspondence between the style parameters as they are represented in a graphical context struct on the input side, and the javascript that needs to be invoked on the output side:


Graphical parameter Graphical context slot Javascript property/method
Line width gc->lwd ctx.lineWidth
Line end gc->lend ctx.lineCap
Line join gc->ljoin ctx.lineJoin
Line mitre gc->lmitre ctx.miterLimit
Line type gc->lty ctx.setLineDash()


On the input side, the slots carry the same meaning that they do in R: whenever calling par() to get and set graphical parameters, you’ll notice that it contains options for lwd, lend, ljoin, lmitre, and lty. On the output side, javascript has the capacity to set any of these style parameters for the canvas, with only a small amount of work required for the translation. The source code shows the details of how the translation is done:

style.c (C code)
void cnvs_set_line_style(FILE *fp, const pGEcontext gc)
{
    fprintf(fp, "  ctx.lineWidth = %.2f;\n", gc->lwd);

    switch (gc->lend) {
    case GE_ROUND_CAP:  fprintf(fp, "  ctx.lineCap = \"round\";\n");  break;
    case GE_SQUARE_CAP: fprintf(fp, "  ctx.lineCap = \"square\";\n"); break;
    case GE_BUTT_CAP:
    default:            fprintf(fp, "  ctx.lineCap = \"butt\";\n");   break;
    }

    switch (gc->ljoin) {
    case GE_ROUND_JOIN: fprintf(fp, "  ctx.lineJoin = \"round\";\n"); break;
    case GE_BEVEL_JOIN: fprintf(fp, "  ctx.lineJoin = \"bevel\";\n"); break;
    case GE_MITRE_JOIN:
    default:
        fprintf(fp, "  ctx.lineJoin = \"miter\";\n");
        fprintf(fp, "  ctx.miterLimit = %.2f;\n", gc->lmitre);
        break;
    }

    /* R encodes a dash pattern as up to 8 hex digits packed into one int,
     * read from the least-significant nibble outward, each nibble giving
     * the length of one dash/gap segment in multiples of the line width
     * (see LTY_DASHED, LTY_DOTTED etc. in R_ext/GraphicsEngine.h, and try
     * `strtoi("42", base = 16L)` style arithmetic by hand on LTY_DASHED to
     * see where the "4" and "4" come from). lty == LTY_SOLID (0) means
     * "no dashing"; LTY_BLANK (-1) means invisible, which callers should
     * check for *before* drawing anything at all. */
    if (gc->lty == LTY_SOLID) {
        fprintf(fp, "  ctx.setLineDash([]);\n");
    } else {
        fprintf(fp, "  ctx.setLineDash([");
        unsigned int lty = (unsigned int) gc->lty;
        for (int i = 0; i < 8; i++) {
            unsigned int segment = lty & 0xF;
            if (segment == 0) break;
            fprintf(fp, "%s%.2f", i == 0 ? "" : ", ", segment * gc->lwd);
            lty >>= 4;
        }
        fprintf(fp, "]);\n");
    }
}

Okay yes there’s a little fancy footwork going on when translating the line type, and okay yes you do need to know that GE_ROUND_CAP, LTY_SOLID, and friends are all defined elsewhere by R itself. If I were writing another graphics device from scratch (without using an LLM coding agent to help me) it would take me a modest amount of work to find all those things and make sure I was using them correctly, but there’s nothing conceptually difficult going on here.9

Code
(R code)
cnvs_embed(
  expr = print(circuit(seed = 108)),
  width = 746,
  height = 400, 
  bg = "#00000000"
)



The device itself: device.c

Okay. At this point I feel like I understand the basics of how my fancy new cnvs device handles the “translating R into javascript” part of the process. But right now, none of this functionality connects into R. It’s just a collection of C functions that take input in a format that just so happens to be exactly what the R graphics engine supplies, and then translates that into some javascript that presumably will end up in an HTML file eventually. I mean, I’m guessing that this what the write.c file will handle for us later on. But right now what I don’t understand is how any of this gets wired into the R graphics engine. How does R know to call any of these functions? And where is this magical cnvs_device_() C function to whom the R code for cnvs() palms off all responsibility?

The answer to the last question is “it’s in the device.c file”, and the code for it – after Claude and I had quite a few back and forths about making the cursed thing comprehensible to me – is shown later in this section. But first, a small digression…

Code
(R code)
# The third and last of the tiny art systems tucked away in
# these code chunks. This one draws some wavy lines across
# the canvas, again using ggsketch to give them a hand-drawn
# appearance

# Data generator
sample_waves_data <- function(seed = NULL, n, width, height){

  if(!is.null(seed)) set.seed(seed)
  dat <- tibble::tibble(
    id = 1:n,
    x0 = runif(n, min = 0, max = width),
    x1 = runif(n, min = 0, max = width),
    ym = runif(n, min = 50, max = height-50),
    ht = runif(1, min = 0, max = 50),
    fq = runif(1, min = .01, max = .1),
    shade = runif(n)
  ) |> 
    dplyr::reframe(
      .by = id,
      wave0 = min(x0, x1),
      wave1 = max(x0, x1), 
      x = seq(wave0, wave1),
      y = ym + ht * sin(fq * seq(wave0, wave1)),
      shade = shade
    )
  return(dat)
}

# Top-level art generator
waves <- function(seed, n = 20, width = 746, height = 150) {
  dat <- sample_waves_data(seed, n, width, height)
  pal <- sample_palette(seed)
  plt <- plot_paths(dat, pal, width, height)
  return(plt)
}

cnvs_embed(
  expr = print(waves(seed = 2005)),
  width = 746, height = 150, bg = "#00000000"
)


Let’s talk about SEXP, baby

Before we look at the full source code to cnvs_device_() it’s worth showing the function signature alone, because it brings up a point that is obvious only to people who are used to writing R extensions in C, namely the SEXP type. Here’s the function signature:

device.c (C code)
SEXP cnvs_device_(SEXP file_, SEXP width_, SEXP height_, SEXP pointsize_, 
                  SEXP bg_, SEXP standalone_, SEXP id_prefix_)

If we ignore that strange SEXP thing, the signature is very clear and indeed exactly matches what the cnvs() function in R expects to receive from the user. To avoid making you scroll up, this is the signature for cnvs() itself, with default values omitted:

cnvs.R (R code)
cnvs(file, width, height, pointsize, bg, standalone, id_prefix)

The arguments that the user passes to cnvs() in R get passed down as the arguments to the cnvs_device_() function in C. That makes sense, but it’s important to recognise that because of the lazy evaluation model in R, the “thing” that gets passed to any R function is not the object itself but an expression. It’s terribly lisp-like in that respect. And because those expressions exist within R, there needs to be some data structure on the C side that represents an R expression. That’s what a SEXP is. It’s an “S-expression”.10 Because cnvs_device_() is the point of contact between the R-side code (i.e., cnvs()) and the C-side code, it receives S-expressions as its input from R and returns an S-expression back to R. It’s useful to know this, but not amazingly relevant to the cnvs package because when you look at the code for cnvs_device_() you can see that the very first thing it does is convert the SEXP objects into more familiar C objects. This function takes SEXP arguments as inputs and returns a SEXP as its output because it is the interface from the graphics device to the R function cnvs() that calls it, but for the most part our graphics device doesn’t want or need to be using these things:

device.c (C code)
SEXP cnvs_device_(SEXP file_, SEXP width_, SEXP height_, SEXP pointsize_, 
                  SEXP bg_, SEXP standalone_, SEXP id_prefix_)
{
    const char *file = CHAR(STRING_ELT(file_, 0));
    int width = asInteger(width_);
    int height = asInteger(height_);
    double pointsize = asReal(pointsize_);
    int bg = RGBpar3(bg_, 0, R_RGB(255, 255, 255));
    Rboolean standalone = (Rboolean) asLogical(standalone_);
    const char *id_prefix = CHAR(STRING_ELT(id_prefix_, 0));

    /* rest of function goes here... */

    return R_NilValue;
}

In short, the interface to R does need to know about SEXP objects, but they don’t play a big role for us in this situation.

Code
(R code)
cnvs_embed(
  expr = print(arcs(seed = 1206)),
  width = 746, height = 150, bg = "#00000000"
)


The interface with R

Having discussed that pesky little SEXP issue, we can now take a proper look at the C code for cnvs_device_(). Because I asked Claude to be extremely verbose with comments, and after a few rounds of “but… can’t this bit go into a helper function?????” interaction, it doesn’t contain a lot of code. The role of this function is to set up the pDevDesc object, and most of that work gets devolved to three helper functions that each do a different part of that work:

  • cnvs_setup_description() is used to set up the various fields that advertise to the R graphics engine what the graphics device can do, and what physical characteristics (e.g., height and width) our device has.

  • cnvs_setup_callbacks() is used to tell the R graphics engine what functions to call whenever it needs to take particular actions (e.g., create a new page, draw a line, etc). I haven’t shown it yet, but you won’t be at all surprised to discover that this is the part of the code where the cnvs_Line() function gets wired into the graphics device.

  • cnvs_setup_state() is used to set up the internal storage that is specific to the canvas device. It creates an object state – a struct of type cnvs_state – for this purpose, and this object is assigned to the dd->deviceSpecific slot. Everywhere else in the code where you see the references to the state object, that’s this thing.

The nice thing about wrapping a lot of these specifics into helper functions is that you can see the structure of what cnvs_device_() does quite easily:

device.c (C code)
SEXP cnvs_device_(SEXP file_, SEXP width_, SEXP height_, SEXP pointsize_, 
                  SEXP bg_, SEXP standalone_, SEXP id_prefix_)
{
    const char *file = CHAR(STRING_ELT(file_, 0));
    int width = asInteger(width_);
    int height = asInteger(height_);
    double pointsize = asReal(pointsize_);
    int bg = RGBpar3(bg_, 0, R_RGB(255, 255, 255));
    Rboolean standalone = (Rboolean) asLogical(standalone_);
    const char *id_prefix = CHAR(STRING_ELT(id_prefix_, 0));

    /* Every device does these two checks before creating itself: confirm
     * we were compiled against headers compatible with the running R's
     * graphics engine, and confirm the engine is willing to let us open
     * another device right now. */
    R_GE_checkVersionOrDie(R_GE_version);
    R_CheckDeviceAvailable();

    /* Create the data structure that holds our own per-device state (the
     * open file handle, and bookkeeping like page count and clip state)
     * that every drawing callback needs in order to write the HTML/JS
     * output. */
    cnvs_state *state = cnvs_setup_state(file, width, height, 
                                         standalone, id_prefix);

    /* Allocate the device description struct, dd -- calloc() only
     * allocates and zeroes it; the real initialisation happens just below,
     * in cnvs_setup_description(). */
    pDevDesc dd = (pDevDesc) calloc(1, sizeof(DevDesc));
    if (dd == NULL) {
        fclose(state->fp);
        free(state->id_prefix);
        free(state);
        error("cnvs: could not allocate device description");
    }

    /* Describe this device to the graphics engine -- its size, character/
     * font metrics, starting graphical parameters, and which optional
     * features it supports. That's all set up in this helper. */
    cnvs_setup_description(dd, width, height, pointsize, bg);

    /* `deviceSpecific` is the slot R's DevDesc struct dedicates to a
     * device's own private data (see the comment on cnvs_state in
     * cnvs.h). For cnvs, that's our own state -- the open file handle and
     * per-device state every drawing callback needs, accessed back out via
     * the CNVS(dd) macro. */
    dd->deviceSpecific = state;

    /* `deviceVersion` tells the graphics engine which bundle of features
     * (see the version history at the top of R_ext/GraphicsEngine.h) this
     * device's callback table actually has slots for. It needs to match
     * what we've genuinely wired up in cnvs_setup_callbacks() just below:
     * we provide (trivial, declining) implementations of the "R_GE_definitions"
     * (13) group -- setPattern/setClipPath/setMask and their release
     * counterparts -- because grid calls those unconditionally (e.g. on
     * every grid.newpage(), to clear any mask) regardless of
     * deviceVersion, so leaving them NULL crashes R the moment grid
     * graphics (ggplot2, lattice -- base graphics never triggers this) is
     * used, even though plain plot() calls look fine. We stop at 13,
     * though, because we do NOT implement the group/path/glyph/variable
     * font callbacks that versions 15-17 require; claiming one of those
     * higher versions without implementing them would reintroduce exactly
     * this kind of crash for whatever grid feature touches groups. */
    dd->deviceVersion = R_GE_definitions;

    /* Every R graphics device must specify callback functions that get
     * triggered whenever the device is asked to take action. Those get
     * set up in this helper function. */
    cnvs_setup_callbacks(dd);

    /* Everything above has just been building our own `dd` in memory --
     * the graphics engine doesn't know it exists yet. These three calls
     * are what actually hand it over and make it "the current device":
     *   - GEcreateDevDesc() wraps our `dd` in the engine's own pGEDevDesc
     *     struct, which adds the engine-level bookkeeping (e.g. the
     *     display list below) that sits on top of a device's own
     *     description. From here on, it's this wrapped `gdd`, not the
     *     raw `dd`, that the rest of the engine operates on.
     *   - GEaddDevice2() registers that wrapped device in R's device list
     *     (what dev.list()/dev.cur() report) under the name "cnvs", and
     *     makes it the new current device, so that plotting calls after
     *     this point in R are routed to our callbacks.
     *   - GEinitDisplayList() sets up the (empty) display-list bookkeeping
     *     the engine keeps per device. We've told it we don't want pages
     *     replayed/resized via dd->displayListOn = FALSE above, but every
     *     device -- ours included -- is still expected to have this
     *     called once up front, the same as pdf()/svg() and friends do. */
    pGEDevDesc gdd = GEcreateDevDesc(dd);
    GEaddDevice2(gdd, "cnvs");
    GEinitDisplayList(gdd);

    /* The outer <html>/<body> scaffolding is written once, up front.
     * Individual pages (one <canvas>/<script> block each) are opened and
     * closed by the newPage/close callbacks as plotting proceeds. */
    cnvs_write_header(state);

    return R_NilValue;
}

Okay so as you can see, the helper functions are doing most of the work setting up the dd object that describes our graphics device. We then have this magic little snippet of code that (per the very long comment above) registers our new device with R, making it the current device in R:

device.c (C code)
pGEDevDesc gdd = GEcreateDevDesc(dd);
GEaddDevice2(gdd, "cnvs");
GEinitDisplayList(gdd);

After that, we call cnvs_write_header() – which is unsurprisingly located in the write.c file – to write the first few lines of HTML boilerplate code into the output file. Once that is done, our device is ready to receive drawing instructions, translate them into javascript, and write them to the HTML file.

Fair enough. Makes sense, I guess?

Code
(R code)
cnvs_embed(
  expr = print(waves(seed = 2007)),
  width = 746, height = 150, bg = "#00000000"
)


Describing our device

Now that we understand the overall structure of what cnvs_device_() does, let’s start a-diggin’ into those helper functions to look at the specifics of what each one does. I’ll start with cnvs_setup_description(). The role of this function is to flesh out the device description dd with the various fields that R needs to know about in order to use the device. I’m not going to describe all of these here, because there are quite a few of them, but I’ll pick out some of the key ones. In the listing below, each entry corresponds to a single field in dd (e.g., left refers to dd->left):

  • left, right, top, bottom. These describe the physical layout of the canvas itself, in pixels. Because the output at the end of this process is an HTML canvas object, it’s set up with the origin located at the top-left of the canvas: the x-coordinate increases from left to right, and the y-coordinate increases from top to bottom. Relative to the way we conventionally think about plot coordinates in R (origin bottom-left, y increasing upward), the y-axis is flipped. Declaring top = 0 and bottom = height tells the R graphics engine that our coordinate system is y-down: the engine itself does the y-inversion, so the C code in cnvs never has to deal with it.

  • clipLeft, clipRight, clipTop, clipBottom. Exactly analogous to the last ones, except that this time we’re describing the location of the clipping region in the plot.

  • xCharOffset, yCharOffset, yLineBias. Constants describing character-size bookkeeping that the graphics engine needs from every device (used e.g. when laying out margins and axis labels). They’re needed so that the engine knows what to do with text, but if you browse the source for the cnvs package you’ll see that the values for these are hardcoded by copying the same values used by the on-screen devices in R.

The full code for this function is shown below: notice that for several of the slots (e.g., haveRaster, haveCapture, haveLocator), the only thing our graphics device does at this stage is state explicitly that it can’t handle these things. If R wants to write a raster object to our canvas, well, too bad. That’s not implemented, and the graphics device won’t do anything.

device.c (C code)
static void cnvs_setup_description(pDevDesc dd, int width, int height, 
                                   double pointsize, int bg)
{
    /* We model the canvas the same way a screen/bitmap device would:
     * origin at the top-left, y increasing downward, one device unit
     * equal to one CSS pixel. That means the coordinates the engine
     * passes into our drawing callbacks are already exactly the numbers
     * we want to write into canvas calls -- no flipping required. */
    dd->left = 0;
    dd->right = width;
    dd->top = 0;
    dd->bottom = height;
    dd->clipLeft = 0;
    dd->clipRight = width;
    dd->clipTop = 0;
    dd->clipBottom = height;

    dd->xCharOffset = CNVS_XCHAR_OFFSET;  /* defined as 0.4900 */
    dd->yCharOffset = CNVS_YCHAR_OFFSET;  /* defined as 0.3333 */
    dd->yLineBias = CNVS_YLINE_BIAS;      /* defined as 0.2000 */
    dd->ipr[0] = dd->ipr[1] = 1.0 / 96.0; /* 96 px per inch */
    dd->cra[0] = 0.9 * pointsize * (96.0 / 72.0);
    dd->cra[1] = 1.2 * pointsize * (96.0 / 72.0);
    dd->gamma = 1;

    dd->canClip = TRUE;        /* we implement the clip() callback */

    /* deviceClip overrides canClip, telling the engine to leave ALL
     * clipping to us rather than also doing its own clipping math on top
     * of ours (see the deviceClip field's comment in
     * R_ext/GraphicsDevice.h). We don't need that -- our clip() callback
     * only ever has to intersect one rectangle, which canvas's own
     * ctx.clip() already does for us -- so we leave the engine's
     * clipping on as a second line of defence instead of overriding it. */
    dd->deviceClip = FALSE;

    dd->canChangeGamma = FALSE;
    dd->canHAdj = 1;  /* we honour hadj at the 3 standard values: 0, 0.5, 1 */

    dd->startps = pointsize;
    dd->startcol = R_RGB(0, 0, 0);
    dd->startfill = bg;
    dd->startlty = LTY_SOLID;
    dd->startfont = 1;
    dd->startgamma = 1;

    dd->haveTransparency = 2;   /* yes -- colours have an alpha channel */
    dd->haveTransparentBg = 2;  /* yes -- fully transparent background is okay */
    dd->haveRaster = 1;         /* no -- raster() callback isn't implemented */
    dd->haveCapture = 1;        /* no -- cap() callback isn't implemented */
    dd->haveLocator = 1;        /* no -- locator() callback isn't implemented */

    /* Unlike a screen device, a file-based device like this one (or
     * pdf()/svglite()) doesn't need R to keep a display list around for
     * replaying/resizing, so we turn it off. */
    dd->displayListOn = FALSE;
}
Code
(R code)
cnvs_embed(
  expr = print(waves(seed = 1008)),
  width = 746, height = 150, bg = "#00000000"
)


Setting up our state

Next let’s look at the state object that is returned when cnvs_setup_state() is called, and gets used in various places throughout the code base. Earlier in the post I talked about the type definition for a cnvs_state object, defined in the cnvs.h header file. This is the object that stores all our device-specific state:

  • state->fp is the open HTML file that the device is writing to
  • state->width and state->height describe the canvas size in pixels
  • state->page_count is the number of pages (canvases) written so far
  • state->clip_active indicates whether a clipping action is happening
  • state->standalone indicates whether the HTML boilerplate headers are needed
  • state->id_prefix is used to assign an id to the canvas

The cnvs_setup_state() helper function is used to create this state. In all honesty there’s not much to say about it, since the type definition we discussed earlier already covered the conceptual aspects to cnvs_state objects, but hey, here’s the code anyway:

device.c (C code)
static cnvs_state *cnvs_setup_state(const char *file, int width, int height,
                                    Rboolean standalone, const char *id_prefix)
{
    cnvs_state *state = (cnvs_state *) calloc(1, sizeof(cnvs_state));
    if (state == NULL) error("cnvs: could not allocate device state");

    state->fp = fopen(file, "w");
    if (state->fp == NULL) {
        free(state);
        error("cnvs: could not open '%s' for writing", file);
    }
    state->width = width;
    state->height = height;
    state->page_count = 0;
    state->clip_active = FALSE;
    state->standalone = standalone;
    /* Copied (rather than pointing straight at R's string storage) because
     * state outlives this call -- R could garbage-collect id_prefix_ long
     * before cnvs_Close() runs. Freed again in cnvs_Close(). */
    state->id_prefix = strdup(id_prefix);
    if (state->id_prefix == NULL) {
        fclose(state->fp);
        free(state);
        error("cnvs: could not allocate id_prefix");
    }

    return state;
}
Code
(R code)
cnvs_embed(
  expr = print(waves(seed = 3009)),
  width = 746, height = 150, bg = "#00000000"
)


Setting up callbacks

Next, let’s look at the cnvs_setup_callbacks() helper function. This is the part of the device setup where we make sure that the device knows what to do when R instructs it to undertake some action. To accomplish this, we need to fill out the various callback slots in dd with the actual functions that the device needs to call. Some of these are things we’ve already seen:

  • The dd->line slot is filled by the cnvs_Line() function from draw.c
  • The dd->rect slot is filled by the cnvs_Rect() function from draw.c
  • The dd->circle slot is filled by the cnvs_Circle() function from draw.c
  • Etc.

There are other slots (e.g., dd->raster and dd->cap) that are left as NULL as a way of signalling to the R graphics engine that our canvas device doesn’t support those actions, and – for tiresome reasons – a few others (e.g., dd->setMask and dd->setPattern) where we “technically” register some callbacks, but none of these actually do anything. They exist only because the grid graphics system always calls these functions, and we don’t want to create a segfault. But in practice these dummy functions are doing the same conceptual work as the callbacks that we set to NULL.

Here’s the code:

device.c (C code)
static void cnvs_setup_callbacks(pDevDesc dd)
{
    /* Callback functions defined in device.c*/
    dd->activate   = cnvs_Activate;
    dd->deactivate = cnvs_Deactivate;
    dd->close      = cnvs_Close;
    dd->newPage    = cnvs_NewPage;
    dd->size       = cnvs_Size;
    dd->mode       = cnvs_Mode;

    /* Callback functions defined in draw.c*/
    dd->line       = cnvs_Line;
    dd->rect       = cnvs_Rect;
    dd->circle     = cnvs_Circle;
    dd->polyline   = cnvs_Polyline;
    dd->polygon    = cnvs_Polygon;
    dd->text       = cnvs_Text;
    dd->clip       = cnvs_Clip;
    dd->strWidth   = cnvs_StrWidth;
    dd->metricInfo = cnvs_MetricInfo;

    /* Everything below is left NULL on purpose, which tells the graphics
     * engine "this device can't do that" -- it will fall back to other
     * means where it can (e.g. approximating a raster image with
     * polygons), or simply skip the feature. Implementing these is a
     * natural set of follow-up exercises:
     *   - path:    arbitrary multi-polygon paths with winding-rule fills
     *   - raster:  bitmap images (would need base64/ImageData encoding)
     *   - cap:     reading pixels back off the device (needs a real
     *              canvas, i.e. a browser -- not possible from a plain
     *              HTML file written from R)
     *   - locator: interactive mouse-click input (not possible for a
     *              file-based, non-interactive device)
     */
    dd->path            = NULL;
    dd->raster          = NULL;
    dd->cap             = NULL;
    dd->locator         = NULL;
    dd->onExit          = NULL;
    dd->getEvent        = NULL;
    dd->newFrameConfirm = NULL;
    dd->eventHelper     = NULL;
    dd->holdflush       = NULL;

    /* These, by contrast, are NOT safe to leave NULL -- see the long
     * comment above cnvs_SetPattern() et al. They're genuine "declines"
     * (we don't support any of this), just expressed as real function
     * calls rather than a missing pointer. Also in device.c */
    dd->setPattern      = cnvs_SetPattern;
    dd->releasePattern  = cnvs_ReleasePattern;
    dd->setClipPath     = cnvs_SetClipPath;
    dd->releaseClipPath = cnvs_ReleaseClipPath;
    dd->setMask         = cnvs_SetMask;
    dd->releaseMask     = cnvs_ReleaseMask;
}

Reading through this code, you can see that there’s another set of callbacks that I haven’t talked about. These are callbacks that don’t directly correspond to drawing actions (hence they aren’t in draw.c) but instead correspond to other ways in which R might ask the device to do something. The device.c file also contains the source code for these callbacks, and to be honest several of these functions do absolutely nothing whatsoever for our canvas device:

  • cnvs_Activate(): What should the device do when it’s made the “active” device in R? In our case, absolutely nothing. This function exists but doesn’t do anything.
  • cnvs_Deactivate(): What should the device do when it’s no longer active? Again, nothing.
  • cnvs_Mode(): What should the device do when R starts a drawing action (mode = 1) or finishes one (mode = 0). For an on-screen device this might be a trigger to flush the device, drawing pixels to the screen. For the canvas device, it does nothing.

There are, however, three non-drawing actions where our device does need to do something:

  • cnvs_Size(): This function gets called any time R needs to know the size of the graphics device. For a file-based device like ours, this is really trivial. It just returns the same dimensions that the user passed in at the very beginning.
  • cnvs_NewPage(): This function gets called once when the device is first created, and again every time that R asks to create a new page (e.g., via grid::grid.newpage(), or when a base R graphic moves to a new panel in the par(mfrow = ...) sense). For our canvas engine, each page corresponds to a distinct <canvas> plus <script> block, and the responsibility of the cnvs_NewPage() function is to set that up.
  • cnvs_Close(): This function gets called when the graphics device is closed (e.g., via dev.off()). For our canvas device, this is where we close out the current page, and write the footer information to the HTML file.

I’ll skip over the cnvs_Size() function in this post, but here’s the source code for the cnvs_NewPage() and cnvs_Close() callbacks:

device.c (C code)
void cnvs_NewPage(const pGEcontext gc, pDevDesc dd)
{
    cnvs_state *state = CNVS(dd);

    /* newPage is called once before the very first thing is drawn, and
     * again every time plotting starts a fresh page (e.g. a second
     * plot() call, or the next panel in par(mfrow = ...)). Each page gets
     * its own <canvas>/<script> block, so if a previous page is open we
     * need to close it out first. */
    if (state->page_count > 0) {
        if (state->clip_active) {
            fprintf(state->fp, "  ctx.restore();\n");
            state->clip_active = FALSE;
        }
        cnvs_end_page(state);
    }
    cnvs_start_page(state, gc);
}

void cnvs_Close(pDevDesc dd)
{
    cnvs_state *state = CNVS(dd);

    if (state->page_count > 0) {
        if (state->clip_active) {
            fprintf(state->fp, "  ctx.restore();\n");
        }
        cnvs_end_page(state);
    }
    cnvs_write_footer(state);
    fclose(state->fp);
    free(state->id_prefix);
    free(state);
    /* `dd` itself (the DevDesc struct) is freed by the graphics engine
     * right after this callback returns -- we only own `state`. */
}

Notice that a lot of the work is farmed out to functions that live in write.c. So we’ll need to take a look at that file now, I suppose?

Code
(R code)
cnvs_embed(
  expr = print(circuit(seed = 909)),
  width = 746,
  height = 400, 
  bg = "#00000000"
)



Writing the HTML: write.c

The role of the write.c file requires a little bit of nuance. It contains four helper functions that are called whenever we need to write something other than a drawing instruction to the HTML file. If you recall from looking at the code for cnvs_Line(), for example, each of the drawing callbacks is responsible for writing its own javascript drawing instructions into the document. The four helper functions in write.c take care of everything else. Let’s have a look at them.


Start and end page

Okay, so that’s the header and the footer. Those two functions are concerned with the HTML scaffolding that is required if the user requested a standalone HTML document as the output. Analogously, then, cnvs_start_page() and cnvs_end_page() are used whenever the device needs to open a new canvas or close that canvas out.

  • cnvs_start_page(): From the perspective of our cnvs device, each “page” (in the R graphics sense) corresponds to a distinct HTML canvas object. So this function is responsible for opening the <canvas> HTML tag, specifying the CSS for styling the canvas, and (because R expects to be able to set the background colour at this time), creating a rectangle for the background:

    write.c (C code)
    void cnvs_start_page(cnvs_state *state, const pGEcontext gc)
    {
        state->page_count++;
        fprintf(state->fp,
            "<canvas id=\"%s-%d\" width=\"%d\" height=\"%d\" "
            "style=\"border: none; margin-bottom: 1em;\"></canvas>\n"
            "<script>\n"
            "(function() {\n"
            "  var canvas = document.getElementById(\"%s-%d\");\n"
            "  var ctx = canvas.getContext(\"2d\");\n",
            state->id_prefix, state->page_count, state->width, state->height,
            state->id_prefix, state->page_count);
    
        /* The graphics engine always calls newPage with the background colour
         * to use for the page (see the `gc` parameter in the `newPage` entry
         * of R_ext/GraphicsDevice.h). We honour it here, before anything else
         * gets drawn, unless it's fully transparent. */
        if (gc != NULL && !R_TRANSPARENT(gc->fill)) {
            char css[32];
            cnvs_colour_to_css(gc->fill, css, sizeof(css));
            fprintf(state->fp, "  ctx.fillStyle = \"%s\";\n", css);
            fprintf(state->fp, "  ctx.fillRect(0, 0, %d, %d);\n", state->width, state->height);
        }
    }

    Notice that this also takes care of some housekeeping: the state->page_count++; line of code at the beginning exists so that we can keep track of the number of pages that the device has opened.

  • cnvs_end_page(). The complementary function to cnvs_start_page() is terribly simple. It gets called after all the drawing instructions have been written into the file, and its only purpose is to close out the <script> tag that was opened when cnvs_start_page() was called:

    write.c (C code)
    void cnvs_end_page(cnvs_state *state)
    {
        fprintf(state->fp, "})();\n</script>\n");
    }

Well, that was simple enough!

Code
(R code)
cnvs_embed(
  expr = print(circuit(seed = 667)),
  width = 746,
  height = 400, 
  bg = "#00000000"
)



A complete device?

At this point in the post, we’re basically done. There are several topics I’ve skipped over: I haven’t talked about the text handling functions, I didn’t really talk about what happens inside cnvs.h and init.c, and a few other things that the cnvs package actually needs to have in order to work as advertised. But none of these things are conceptually critical. My goal for the cnvs package was purely pedagogical. I wanted to use this package as a vehicle to help me understand how an R graphics device is constructed, and for that reason I focused most heavily on the device.c and draw.c files with a briefer look at style.c and write.c, because those are the places where most of the core work gets done. The post isn’t a complete description of the device, of course, but it’s enough to understand the key features of an R graphics device, and works nicely as a launching pad to dive deeper into the code base for cnvs and from there develop a better understanding of what the R graphics engine does.



What did I learn?

There’s been a lot written about the ways in which using LLMs like Claude can be harmful to users. I’ve experienced it myself: using an agent to solve your problems on a regular basis slowly erodes your ability to do the same task yourself. I worry about it endlessly, and I find the argument that “we don’t need those skills anymore, AI makes them obsolete” thoroughly unconvincing. The analogy people often make is to a calculator: the ability to do long division by hand isn’t a useful skill anymore because the machine is so much faster and accurate at that task.

That’s true as far as it goes, but if you pull at the loose threads in that analogy, it undercuts the argument. There’s a good reason why my kids’ maths classes still teach people to memorise basic multiplication tables, why they teach heuristics for approximate mental arithmetic, and why we still have to learn core numerical skills even when machines can do the same thing faster: numeracy is an essential cognitive skill, it’s one that does not come naturally, and it’s one we use all the time in everyday life. If you cannot quickly and effortlessly answer a question like “what’s 35 divided by 7?” it becomes harder to reason about richer numerical problems because the low-level building blocks for that kind of reasoning aren’t there. If the basic numeric relationships no longer exist in your head, then you can’t use them for anything. It is for this reason that calculators didn’t make mental arithmetic obsolete: the only thing they actually did is reduce our reliance on the more fragile and infrequently-needed parts of that skill. Calculators notwithstanding, humans still need to understand numbers.

So where does that leave those of us who notice that Claude and friends are eroding certain parts of our programming and analytical skill set? I don’t pretend to have a comprehensive answer, but there are a few things that are starting to stand out to me. First, if you want to keep the basics intact (and you should want this!), you do have to keep practicing them. My blog post on writing my own de Bruijn tiling code feels like an example of that: taking the time to do a thing myself even though the machine could have done it faster is valuable in itself. I can’t remember the last time I wrote my own code for a breadth-first search, and it was a nice refresher.

But there’s another side to this, another kind of task that needs doing. One of the reasons why AI usage becomes so problematic, in my limited experience, is that it takes tasks that used to be in the challenging-but-not-overwhelming level of difficulty that makes me think, and transforms them into mind-numbing vigilance tasks. I end up babysitting the AI while it does all the work, and it doesn’t require a lot of thinking at my end. Coding becomes boring.

To my mind that’s actually the worst effect that AI use has in the coding and analysis space. Making the coding easy has a side-effect of making it so unimaginably boring that you stop paying attention, and when you stop paying attention that’s when the inevitable “vibe coded catastrophic fuck up” slips through your filters. Vigilance tasks are actually very hard: when your job is to stand on a code production line and click “yes” 99 times in a row, it’s really fucking hard to notice that on the 100th event you need to step in and click the “no dear god do not fucking rm -rf the company database” button. Boredom is really, really dangerous in that sense.

Remediating the boredom requires something more than just spending time writing code by hand. You actually have do something that forces coding to become difficult again. In this instance, I tried to use the infinite patience and insanely cheap compute costs of the LLM to pull a problem from the “this is overwhelmingly difficult so I won’t even try” level of difficulty down to the “tricky but interesting” level. By asking Claude to implement a pedagogically-focused graphics device, I was able to put myself into a good position to learn something new. Although very little of the code in the cnvs package is my work, everything in this blog post is my own work. The understanding I developed by reading through the cnvs code, by writing about it, by asking questions about it, and by the little bit of tinkering I did with the package code on my own… all of that is real learning. I now have a baseline level of knowledge that is quite a bit higher than where it was before, and the task no longer seems beyond my capabilities. I learned something new, and that makes me happy.

Before all this started, the most I could have told you about R graphics devices is that they’re weird and terrifying beasts that live in darkness (i.e., in the C code) and feed off your fears. Am I an expert on them now? Oh my no. Would it be easy for me to write my very own graphics device without assistance? Not really. But it wouldn’t be impossible.

Looking back on this exercise, it seems like the reason this worked is that my goal was never to build a thing or ship a product. The purpose of the cnvs package is not to create a CRAN-ready package for other people to use, it was to provide me with the scaffolding I needed to drive my own learning. The package was a means to an end, not the end in itself. It did not relieve me of the burden of doing the cognitive labour myself. That part of the process is still there, it is still hard work, and it is still slow. But it is not a wasteful thing to be ironed out or smoothed over, that’s the part that has intrinsic value. The code itself… not so much.

It seems a pity, then, that 99% of the situations in which I see people being pushed down the “use AI uuuuuuuuuuuuuuuuse it girl use it now or elssseeee bad things will happen” path are not these instructive, skill-building exercises. The capitalist imperative has no interest whatsoever in my personal development, in my skills, or indeed in my happiness. The work of learning things is a mere inefficiency in the profitmaxxing world. When the goal is to maximise profit AI will inevitably be pushed as a tool for doing things faster, creating more widgets, shipping more product, and so on. Consequently it becomes this soul-destroying miasma that melts your critical thinking and dulls the shine of life itself.

But it doesn’t have to be. Using AI to deskill workers is a choice. It’s a choice we’re making as a society. We could choose something different. Unfortunately, we probably won’t.

Code
(R code)
cnvs_embed(
  expr = print(circuit(seed = 1, height = 800)),
  width = 746,
  height = 800, 
  bg = "#00000000"
)

Footnotes

  1. My ambivalence about coding agents is well-documented at this point (e.g., here and here). I despise the path by which our society decided to create these things: I am not a fan of rich assholes whose business model is based on mass theft of other people’s intellectual and creative output, relies heavily on exploiting cheaper labour in poorer nations, and whose primary function in society right now is to concentrate power in the hands of some of the worst people on the fucking planet. I don’t blame Claude itself for the original sin that underpins its creation (that would be absurd on several levels at the same time!), but I do not like the AI industry, and my contempt for the owners of this technology exceeds my capacity to express in the English language. But just like everyone else embedded in a capitalist system my labour is a tradeable resource, and I’m obliged to operate within the system that pays me to live. I’m already forced to use the master’s tools; so while those tools will absolutely not dismantle the master’s house, and with all due apologies to Audre Lorde, I might as well use them to build my own cottage. If I can use Claude to help me learn something I wouldn’t otherwise have been able to understand, I’ll pay the toll to our lords and masters.↩︎

  2. Okay, if I’m honest, the other part of the task that was new to me is the R-to-C interface accessible via .Call(). I’m far more comfortable with the Rcpp package than the native interface to C, so that part was a useful side benefit, though not my main focus. I’m skipping that part of the code entirely in this post, but obviously it is suuuuuper important – the package doesn’t work without it!↩︎

  3. It should be noted that the “write to tempfile then read it back in” trick is grossly inefficient given the vast differences in disk access times and memory access times. But in this context I simply don’t care: the cnvs package isn’t intended for real-world usage. It’s purely pedagogical, and for that purpose it is much simpler and cleaner to design the cnvs() graphics device to be a pure “write-to-file” device.↩︎

  4. Admittedly C is less horrible than writing assembly, but jesus christ that is a low bar.↩︎

  5. This one is not super relevant for a file-based device like cnvs() but for interactive devices resizing is relevant.↩︎

  6. A nice anecdote: when Claude originally created this file it made the peculiar error of calling it colour.c. That’s a bad name for what it does, because colour is only one of the style properties that a graphics device needs to handle. My best guess is that when it started its own process the first stylistic thing it considered was colour, and named the file on that basis. The other functions were presumably added later. Fair enough, and I’ve been guilty of exactly that kind of anchoring behaviour in my own coding, but it was kind of a nice feeling that when I started trying to learn from the Claude code one of the first things I noticed was that colour.c is a dumb name for this file, and when I queried this the LLM immediately recognised its error and fixed it. There were several cases where the LLM and I had some useful interactions like this, in which I asked a naive question and that served as a useful prompt for it to fix an issue with the code it had written. Sometimes “learning as an interactive process” does what it says on the tin.↩︎

  7. Very roughly…↩︎

  8. Actually this isn’t true: what I really did first was read all the files, flick back and forth between those things and the documentation, scream and cry a lot, and endure a great deal of incoherent pain before some of the chaos started to crystallise into an initial mental model of the code. The thing I’m calling the “first” action in this blog post is actually “the first thing I did when I felt confident enough to start describing the C code”. That’s a different beast, but unfortunately the “Danielle flails wildly in her total incomprehension of the code” part doesn’t translate well into a blog post. So let’s just take it as a given that there was indeed a “step 0” in which I spent a lot of time crying, yeah?↩︎

  9. I do love the fact that Claude used the term “nibble” in the comments. That was the first time I’ve come across it: in the same sense that a byte is comprised of 8 bits, a nibble is comprised of 4 bits. Cute.↩︎

  10. For once, the “S” in “S-expression” doesn’t refer to the S language that was the precursor to R. In this context it’s short for “symbolic”, and refers to the same kind of symbolic expression that is most closely associated with lisp.↩︎

Reuse

Citation

BibTeX citation:
@online{navarro2026,
  author = {Navarro, Danielle},
  title = {How to Build a Custom {R} Graphics Device},
  date = {2026-10-18},
  url = {https://blog.djnavarro.net/posts/2026-10-18_canvas-graphics/},
  langid = {en}
}
For attribution, please cite this work as:
Navarro, Danielle. 2026. “How to Build a Custom R Graphics Device.” October 18. https://blog.djnavarro.net/posts/2026-10-18_canvas-graphics/.