GoFigr explains why plot capture works in Python but not R
Summary
GoFigr captures plots in Python via Jupyter's single display system, making auto-publish reliable. In R, the graphics architecture lacks a central point, so auto-publish is experimental. Explicitly piping plots to publish() is recommended for R users as it's more reliable and idiomatic.
GoFigr's plot capture works differently in Python and R
GoFigr, a platform for publishing and sharing data visualizations, can automatically capture plots in Python's Jupyter environment but not in R. The reason is a fundamental architectural difference between how the two ecosystems handle display output.
For automatic capture to work, GoFigr needs two things. It must obtain the original plot object for re-rendering and watermarking. It also needs to capture the source code and execution context to ensure the figure is reproducible.
In an ideal world, all plot output would flow through a single point that can be monitored. Python’s Jupyter ecosystem provides this. R does not.
Python's single funnel through IPython
In Jupyter, every rich output—matplotlib figures, Plotly charts, DataFrames—flows through one object: the IPython shell's DisplayPublisher. When code calls `display()` or a cell auto-displays a result, IPython serializes the object into a dictionary of MIME types and sends it to `display_pub.publish()`.
GoFigr inserts a thin wrapper to replace the native DisplayPublisher. This wrapper intercepts every single display call in the notebook session.
The installation is one line in the extension's code:
self.shell.display_pub = GfDisplayPublisher(native_display_publisher, display_trap=self.display_trap)
From that point on, every visualization library that calls `display()` passes through GoFigr's wrapper. This includes matplotlib, plotly, and py3Dmol.
Finding the original plot object
Intercepting the display call gives GoFigr the MIME data, but it needs the original figure object. The system uses stack inspection to find it.
When the display trap fires, GoFigr walks up the call stack to locate the originating library's display function and extracts the figure from its arguments. Separate backend classes handle different libraries.
- MatplotlibBackend looks for frames where the function is "display" and the filename contains "IPython" or "matplot".
- PlotlyBackend looks for frames where the function is "show" and "plotly" is in the filename.
The system uses a helper function, `get_all_function_arguments`, to iterate over all arguments in a stack frame. The developers admit this is a hack, but it's a stable one. The display paths in these libraries haven't changed in years.
How R graphics work differently
R's system is built on graphics devices, which are C-level structs implementing about 30 callback functions for drawing primitives like lines and text. When you call `plot(x, y)`, low-level functions call the active device's C callbacks directly.
There is no equivalent to IPython's DisplayPublisher. The graphics device receives a stream of drawing commands with no knowledge of the source library or code.
The key difference is stark. In Python, there’s a funnel. In R, there’s a fan-out directly to the device.
This is compounded by a split in R's plotting paradigms. ggplot2 and lattice are object-oriented; they construct a plot object rendered by `print()`. Base R graphics are imperative; `plot(x, y)` immediately draws to the device with no "plot object" to intercept.
GoFigr's two paths for R
Given these constraints, GoFigr's R client offers two approaches. The recommended method is explicit: users pipe their plots to `gofigR::publish()`. This is idiomatic for R users accustomed to pipelines.
The client also offers an experimental auto-publish mode. This overrides `print()` in the global environment to intercept supported plot objects before they reach the device. It uses `ggplotify::as.ggplot()` to test if an object can be converted, covering many common cases like ggplot2, lattice, and ComplexHeatmap.
However, the edge cases are numerous. Auto-publish fails for base R graphics, plots rendered inside loops or functions without explicit print calls, and grid graphics created with `grid.draw()`. It also conflicts with packages like shiny or plumber that manage their own output.
Because of these issues, GoFigr recommends the explicit piping method for reliability.
Why not a custom R graphics device?
A natural question is why GoFigr doesn't write a custom graphics device, since all output converges there. The problems are significant.
- No high-level context: The device only sees low-level drawing commands, not source code, titles, or data frames.
- No plot boundaries: There's no signal for when a user is done composing a plot, especially with sub-plots or incremental drawing.
- Device conflicts: If a user opens another device like `png()`, drawing commands would divert away from GoFigr.
- knitr conflicts: The tool would compete with knitr's own devices in R Markdown, creating unpredictable behavior.
- High implementation cost: Writing a correct graphics device is a substantial C/C++ project.
The cost and complexity ratio is extremely unfavorable, and the result would be worse than the current approach for everything except base graphics.
A consequence of architectural history
The difference between Python and R plot capture isn't about implementation cleverness. It's a consequence of decades-old architectural decisions.
Python's Jupyter ecosystem was designed around a message-passing protocol where all rich output is serialized and sent through a well-defined channel. IPython's DisplayPublisher is the interface to that channel.
R's graphics system was designed around a low-level, C-based device abstraction inspired by pen-plotters. Libraries write directly to the device using drawing primitives.
GoFigr's R client embraces this reality. It offers experimental auto-publish but recommends the explicit `publish()` path. This works with every library, avoids edge cases, and fits R's pipe-oriented style. Sometimes the right engineering decision is to design an ergonomic workflow around a platform's limitation.
Related Articles
Python virtual environments isolate project dependencies to prevent conflicts
Use local virtual environments to isolate Python project dependencies, preventing version conflicts and ensuring each project runs reliably with its own packages.
Researcher maps ARM64 instruction set into 2D Hilbert curve visualization
Visualized ARM64 instruction set & LFI (Lightweight Fault Isolation) sandbox's legal instructions using Hilbert curves. Custom tools parse ARM spec; patterns reveal instruction classes and LFI's security restrictions.
Stay in the loop
Get the best AI-curated news delivered to your inbox. No spam, unsubscribe anytime.

