wilkelab / gridtext

Improved text rendering support for grid graphics in R
https://wilkelab.org/gridtext
Other
96 stars 17 forks source link

Gradient fills with R 4.1 #17

Open jimjam-slam opened 3 years ago

jimjam-slam commented 3 years ago

Hi Claus,

I'm thinking of having a play with R-devel to see what the new grid gradient support is like:

The ‘grid’ package now allows ‘gpar(fill)’ to be a ‘linearGradient()’, a ‘radialGradient()’, or a ‘pattern()’.

My naive attempt to see if this integrates with gridtext and ggtext is to just straight-up supply a grid::linearGradient in lieu of a colour string for fill arguments. Do you think it's likely to be that simple, or will it require some pass-through?

I understand dev is frozen on this package for now. Depending on the complexity, I might be able to submit a PR to enable this with a little guidance (or maybe I get lucky and it doesn't need any changes at all).

clauswilke commented 3 years ago

It's quite possibly that simple. Worth it to try out.

jimjam-slam commented 3 years ago

Unfortunately not!

library(tidyverse)
library(ggtext)

{
  ggplot(mtcars) +
    aes(mpg, hp) +
    geom_point() +
    theme(
      plot.title = element_textbox(
        colour = "white",
        fill = grid::linearGradient(
          colours = c("#020024", "#102b81", "#833ab4"),
          stops = c(0, 0.4, 1)))) +
    labs(title = "Hello gradients!")
} %>% ggsave('test.pdf', .)
Saving 7 x 7 in image
# Error in bl_render(x$vbox_outer, x_pt, y_pt) :
#   Not compatible with STRSXP: [type=list].

I'm not so familiar with how STRSXP works, but I'll keep tinkering!

clauswilke commented 3 years ago

Ok. It means that somewhere in the code base we assume that fill is a string. May be difficult to track down and change.

jimjam-slam commented 3 years ago

I should note that this example, modelled on the one in the technical report for the gradients feature, does work:

library(tidyverse)
library(ggtext)
library(grid)

cairo_pdf('test.pdf')

p1 <-  ggplot(mtcars) +
  aes(mpg, hp) +
  geom_point() +
  theme_grey(base_size = 16) +
  theme(
    plot.title = element_textbox(
      colour = "white",
      fill = 'blue')) +
  labs(title = "Hello gradients!")
p1

grid.force()

# use grid.ls() to identify the title background
# here, it's "gridtext.ect.7"
grid.edit("gridtext.rect.7", grep = TRUE,
  gp = gpar(fill = linearGradient(
    colours = c("#020024", "#102b81", "#833ab4"),
    stops = c(0, 0.4, 1))))
dev.off()

image

jimjam-slam commented 3 years ago

So I can see one place where this might be explicitly assumed, in grid-renderer.h at line 21:

RObject gpar_lookup(List gp, const char* element) {
  if (!gp.containsElementNamed(element)) {
    return R_NilValue;
  } else {
    return gp[element];
  }
}

(It looks like all gpar elements are assumed to be strings, which squares with the existing docs.) Nope, my bad; those are the names of gp elements.

This is called for fill further down, when rect() is deciding whether to draw anything (grid-renderer.h at line 74):

RObject fill_obj = gpar_lookup(gp, "fill");

if (!fill_obj.isNULL()) {
  CharacterVector fill(fill_obj);
  if (fill.size() > 0 && !CharacterVector::is_na(fill[0])) {
    have_fill_col = true;
  }
}

As far as I can tell, fill_obj and fill don't go any further; this appears just to be about flagging have_fill_col in order to decide whether to draw a grob (although gp does get passed wholesale to either rect_grob() or roundrect_grob()).

I'm not entirely sure how to modify these sections to accommodate fill potentially being a list, though. Can we declare element as an RObject instead? Will that still work if it is indeed a string? Will gp.containsElementNamed() and fill.size() work?

Maybe CharacterVector fill(fill_obj); is the critical line here?

jimjam-slam commented 3 years ago

I tried running with this. No errors, but specifying a gradient silently produced a corrupt PDF. (I broke regular fills but got 'em going again, haha.)

RObject fill_obj = gpar_lookup(gp, "fill");

  if (!fill_obj.isNULL()) {

    // check fill object presence depending on type
    if (fill_obj.inherits("GridPattern")) {
      // gradient or pattern
      have_fill_col = true;
    } else {
      // a string (neither a gradient nor a pattern)
      CharacterVector fill(fill_obj);
      if (fill.size() > 0 && !CharacterVector::is_na(fill[0])) {
        have_fill_col = true;
      }
    }

I wonder if maybe text_grob, rect_grob and roundrect_grob in grid.cpp need to do some translation to make sure the GridPattern lists make it back out the other side :/

jimjam-slam commented 3 years ago

Ah! But this modification does work for PNGs!

library(tidyverse)
library(ggtext)
{
  ggplot(mtcars) +
  aes(mpg, hp) +
  geom_point() +
  theme_grey(base_size = 20) +
  theme(
    plot.title = element_textbox(
      colour = "white",
      fill = grid::linearGradient(
    colours = c("#020024", "#102b81", "#833ab4"),
    stops = c(0, 0.4, 1)))) +
  labs(title = "Hello gradients!", subtitle = "Another go")
} %>% ggsave('test_grad_title.png', ., device = png(type = "cairo"))

test_grad_title

Maybe there's something going wrong with the PDF device when I do it this way... it definitely worked with the PDF device when I did it using grid.edit().

More work to do for geom_textbox too (which makes sense to me, since there're lots of ways data columns could work as aesthetics inside a gradient):

{
  ggplot(mtcars %>% rownames_to_column()) +
  aes(mpg, hp) +
  # geom_point() +
  geom_textbox(
    aes(label = rowname),
    colour = "white",
    fill = linearGradient(
      colours = c("#020024", "#102b81", "#833ab4"),
      stops = c(0, 0.4, 1))
  ) +
  theme_grey(base_size = 20) +
  theme(
    plot.title = element_textbox(
      colour = "white",
      fill = linearGradient(
    colours = c("#020024", "#102b81", "#833ab4"),
    stops = c(0, 0.4, 1)))) +
  labs(title = "Hello gradients!", subtitle = "Another go")
} %>% ggsave('test_grad_title2.png', ., device = png(type = "cairo"))
# Saving 6.67 x 6.67 in image
# Error: Aesthetics must be either length 1 or the same as the data (32): fill
clauswilke commented 3 years ago

Just FYI, you're welcome to try things out and see where you get, but please don't expect much assistance from me going forward. I'd much rather spend my limited time on the next iteration of the code base.

jimjam-slam commented 3 years ago

Very reasonable 🙂 Thanks for the heads up!

jimjam-slam commented 3 years ago

(Also adding a note to myself that this issue has been discussed in https://github.com/tidyverse/ggplot2/issues/3997 for the more general scope of ggplot2... might want to watch to see if that moves before I try to bite anything wild off with geom_textbox).

jimjam-slam commented 3 years ago

False alarm! I was misspecifying the PDF device. This example works for both PDF and PNG:

myplot <-
  ggplot(mtcars) +
  aes(mpg, hp) +
  geom_point() +
  theme_grey(base_size = 20) +
  theme(
    plot.title = element_textbox(
      colour = "red",
      fill = grid::linearGradient(
        colours = c("#020024", "#102b81", "#833ab4"),
        stops = c(0, 0.4, 1)))) +
  labs(title = "Hello gradients!", subtitle = "Another go")

ggsave('test_grad_title.png', myplot, device = png(type = "cairo-png"))
ggsave('test_grad_title.pdf', myplot, device = cairo_pdf)

Tested on both Linux (using the rocker/tidyverse:r-devel image) and Windows :)