# Grover renders HTML to PDF by handing it to a Chromium you already installed

> Grover is a Ruby gem that converts a URL or an inline HTML string into a PDF, PNG or JPEG by driving a Puppeteer-managed Chromium, with configuration for margins, viewport, media emulation and timeouts. It fits a Rails application that needs to render a view template to PDF, and it does not fit a deployment that cannot run a headless browser.

**Studiosity/grover** — A Ruby gem to transform HTML into PDFs, PNGs or JPEGs using Google Puppeteer/Chromium

- Repository: https://github.com/Studiosity/grover
- Stars: 1,086 · Forks: 125
- Language: Ruby
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/studiosity-grover

## The gem is a client for a browser you have to install separately

Grover is a Ruby gem to transform HTML into PDFs, PNGs or JPEGs using Google Puppeteer and Chromium. The implementation is a Node library, not a pure Ruby renderer, and that is the single most important fact about the project.

The installation has two halves. The Gemfile gets the Ruby side:

```ruby
gem 'grover'
```

And the browser side comes from npm:

```bash
npm install puppeteer
```

The repository confirms this. There is a package.json at the root alongside the grover.gemspec, the Gemfile and the Rakefile, and the package.json declares puppeteer at ^24.35.0 as its one devDependency, with the name, version and description mirroring the gem.

So Grover is a bridge. The Ruby code drives a Puppeteer-controlled Chromium over a socket, tells it to load a URL or an HTML string, and asks it for a PDF or a screenshot. The rendering fidelity is Chromium's rendering fidelity, which is the point, because a browser is the only HTML renderer that agrees with what the user sees.

The cost is a Node toolchain and a browser binary in a Ruby application's deployment. A Rails app that previously needed nothing but a Ruby image now needs Chromium and Puppeteer installed alongside it, and that is a real constraint on the container image size and on the CI setup.

## One constructor, four outputs, and options that can travel in meta tags

The API is deliberately small. Grover.new accepts a URL or inline HTML and optional parameters for Puppeteer, and the object then produces output in whichever format you ask for:

```ruby
grover = Grover.new('https://google.com', format: 'A4')

# Get an inline PDF
pdf = grover.to_pdf

# Get a screenshot
png = grover.to_png
jpeg = grover.to_jpeg

# Get the HTML content (including DOCTYPE)
html = grover.to_html
```

The to_html method is the one worth noting, because it returns the HTML including the DOCTYPE rather than a rendered artefact. That is what makes the preprocessor approach in the relative paths section possible, and it is also what lets you inspect what Chromium actually received when something renders wrong.

Options can also be provided through meta tags in the document itself:

```ruby
Grover.new('<html><head><meta name="grover-page_ranges" content="1-3"')
Grover.new('<html><head><meta name="grover-margin-top" content="10px"')
```

The naming convention is documented as a note: options are underscore case, and sub-options are separated with a dash. That is why the meta tag is grover-page_ranges and grover-margin-top, and the same rule applies to the launch args meta tag shown later, grover-launch_args.

Everything can be overwritten, including emulate_media and display_url, which matters because those two have defaults that are wrong for many deployments.

## Rendering a Rails view template is the case it was built for

The section titled From a view template is the shortest in the README and is the reason most people reach for this gem.

It's easy to render a normal Rails view template as a PDF, using Rails' render_to_string:

```ruby
html = MyController.new.render_to_string({
  template: 'controller/view',
  layout: 'my_layout',
  locals: { :@instance_var => ... }
})
pdf = Grover.new(html, **grover_options).to_pdf
```

The ordering here is the point. You render the view to a string with the normal Rails machinery, so the template, the layout and the locals all work exactly as they do for an HTML response, and then you hand the resulting string to Grover as inline HTML. There is no separate PDF template, no second rendering path and no duplicated view logic.

That is the design that a Rails application wants, and it is why the gem is a good fit for invoices, reports and any other document that is a view with a different output format.

The alternative approaches each cost something. A dedicated PDF template library means maintaining two versions of every document. A headless browser called directly from application code means losing the option abstraction, and in a Rails app the view rendering is the part you do not want to reimplement.

The trade is that the document is HTML, so it is laid out by a browser. That gives you CSS, web fonts, and flexbox or grid, and it takes away typographic control, baseline grids and the page-break discipline of a proper layout engine.

## Relative paths break unless you set a display_url or pre-process the HTML

The relative paths section is the one that saves people an afternoon, and it explains a behaviour that otherwise looks like a bug.

If you call Grover directly rather than through middleware, you need to either specify a display_url or modify the HTML by converting relative paths to absolute paths before passing it to Grover. The helper for that is:

```ruby
absolute_html = Grover::HTMLPreprocessor.process relative_html, 'http://my.server/', 'http'
```

The reason is in the next paragraph. Chromium will try to resolve any relative paths via the display url host, and if it is not provided, the display URL defaults to http://example.com. So a Rails view that references /assets/application.css renders with a broken stylesheet, silently, and the PDF looks unstyled.

The README then asks the right question, why would you pre-process the HTML rather than just use the display_url, and answers it with a list of scenarios. Your server might be behind a NAT gateway and the display URL in front of it. The display URL might be shown in the header or footer, and as such should not expose details of your private network.

That second reason is a security consideration, not a convenience. The display URL appears in the generated document, so setting it to your internal hostname leaks the internal name of your infrastructure to whoever receives the PDF.

If you run into trouble, the debugging section is the next step, and the README points at it as a way to inspect the page content and devtools.

## The configuration block is a Puppeteer options surface with a Ruby hash

The Configuration section shows the whole option surface in one Ruby hash, and it is a faithful mapping of Puppeteer's own options.

```ruby
# config/initializers/grover.rb
Grover.configure do |config|
  config.options = {
    format: 'A4',
    margin: {
      top: '5px',
      bottom: '10cm'
    },
    user_agent: 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0',
    viewport: {
      width: 640,
      height: 480
    },
    prefer_css_page_size: true,
    emulate_media: 'screen',
    bypass_csp: true,
    media_features: [{ name: 'prefers-color-scheme', value: 'dark' }],
    timezone: 'Australia/Sydney',
    vision_deficiency: 'deuteranopia',
    extra_http_headers: { 'Accept-Language': 'en-US' },
    geolocation: { latitude: 59.95, longitude: 30.31667 },
    focus: '#some-element',
    hover: '#another-element',
    cache: false,
    timeout: 0, # Timeout in ms. A value of `0` means 'no timeout'
    launch_timeout: 3000, # Timeout when launching the browser
    request_timeout: 1000, # Timeout when fetching the content (overloads the `timeout` option)
    convert_timeout: 2000, # Timeout when converting the content (overloads the `timeout` option, only applies to PDF conversion)
    launch_args: ['--font-render-hinting=medium'],
    wait_until: 'domcontentloaded'
  }
end
```

Several of these deserve a note. The media emulation options, emulate_media plus media_features with prefers-color-scheme, are how you render a print document with a dark theme, since the default print media loses the colours. vision_deficiency accepts achromatopsia, deuteranopia, protanopia, tritanopia, blurredVision or none, which is a testing tool for colour accessibility. bypass_csp is there for pages whose own content security policy would otherwise stop the injected rendering.

The three timeouts are separate on purpose, and the comments explain the relationship: request_timeout overloads timeout, and convert_timeout also overloads timeout but applies only to PDF conversion, with a comment that a value of 0 means no timeout.

The README also links the upstream Puppeteer documentation for each option group, PDF options, PNG and JPEG options, viewport options and launch args, so the Ruby names map onto a documented Puppeteer surface rather than onto something invented.

## Waiting for the page is the difference between a blank PDF and a correct one

The final group of options in the README is about waiting, and it is where most of the practical work of using this gem happens.

wait_until has different defaults depending on the input. The default for URLs is networkidle2, and for HTML content it is networkidle0. The available values are in the Puppeteer documentation for the goto method.

Then there are the more precise waits. wait_for_selector waits until an element appears on the page, with additional parameters in a wait_for_selector_options hash. wait_for_function waits until a specific function returns a truthy value, with additional parameters in wait_for_function_options. wait_for_timeout waits a specified number of milliseconds to have elapsed.

The focus and hover options are tied into the same machinery. The focus option takes a CSS selector and focuses the first matching element after rendering is complete, including waiting for the specified wait_for_selector, and hover does the same for hovering. That is the behaviour a screenshot of a menu or a dropdown needs, and it is a detail that only works because the library waits for the element rather than assuming it is there.

The failure options are the ones to configure in production. The raise_on_request_failure option, when enabled, raises a Grover::JavaScript::RequestFailedError if the initial content request or any subsequent asset request returns a bad response or times out. And raise_on_js_error raises a Grover error when JavaScript on the page throws, and the README notes the behaviour can also be set with a meta tag.

Launch parameter args can also be provided using a meta tag, for example <meta name="grover-launch_args" content="['--disable-speech-api']" />.

For images there is one more default worth knowing. The full_page option is set to false by default, so you get an 800x600 image, and you either specify the image size with the clip options or set full_page to true to capture the entire page.

## Where Grover is the wrong tool

The first wrong-tool case is a production environment that cannot run a browser. Grover needs Chromium and Puppeteer installed, and a container image for a Rails application that renders PDFs will grow accordingly, along with the memory and the cold-start cost of a browser process per document. A pure Ruby or pure Python HTML-to-PDF renderer is smaller and starts faster, at the cost of matching what a browser shows.

The second is a document that needs typographic control. A browser print stylesheet gives you CSS and web fonts, and it does not give you a baseline grid, precise page breaks, widow and orphan control, or a real text engine. If the document is a contract or a report where the typography is the point, Grover is the wrong tool, and a layout engine is the right one.

The third is a document that is not HTML. Grover renders HTML, so anything you have to produce from structured data means building the HTML first. A report generator that starts from a data model and emits a document has no reason to route through a browser.

The fourth is a dynamic page that will not settle. The wait_until and wait_for_selector options exist because pages load asynchronously, and a page with a carousel, a websocket feed or a third-party script that never idles will keep the render waiting, which is what the timeouts are for. And the last push was on 2026-04-02, with v1.2.10 released the same day after v1.2.9, so the project is maintained but on its own cadence.

## Conclusion

Grover fits a Rails application that has to turn a view template into a PDF or an image, where a browser-based renderer is acceptable and the Chromium dependency can be managed. It does not fit a production environment that cannot run a headless browser, or a document that needs typographic control that a browser print stylesheet will not give you. Before adopting it, confirm the Puppeteer install is part of your deployment, since the gem needs it present, and read the relative paths section, because a Rails view with relative asset paths will render broken unless you set a display_url or run the HTML preprocessor.

## FAQ

### How do I install Grover and its browser dependency?

Add gem 'grover' to your Gemfile, and install the browser side with npm install puppeteer. The gem drives a Puppeteer-managed Chromium, so both the Ruby package and the Node dependency are needed.

### How do I render a Rails view template to a PDF with Grover?

Render the view to a string with Rails' render_to_string, then pass that HTML to Grover and call to_pdf, for example pdf = Grover.new(html, **grover_options).to_pdf. The template, layout and locals all work as they do for an HTML response.

### Why do my relative asset paths break in Grover?

Chromium resolves relative paths via the display URL host, and the display URL defaults to http://example.com when it is not provided. You can either set a display_url or convert the paths yourself with Grover::HTMLPreprocessor.process, which also avoids exposing a private hostname in the rendered header or footer.

### What are the difference between to_pdf, to_png, to_jpeg and to_html in Grover?

to_pdf returns an inline PDF, to_png and to_jpeg return screenshots, and to_html returns the HTML content including the DOCTYPE. For images, the full_page option defaults to false so you get an 800x600 image unless you set the clip options or enable full_page.

## Sources

- [Issues](https://github.com/Studiosity/grover/issues)
- [License: MIT](https://github.com/Studiosity/grover/blob/main/LICENSE)
- [README](https://github.com/Studiosity/grover/blob/main/README.md)
- [Releases](https://github.com/Studiosity/grover/releases)
- [Studiosity/grover on GitHub](https://github.com/Studiosity/grover)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/studiosity-grover
