Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Print xaringan slides to PDF, even the complicated ones
#' Print xaringan slides to PDF
#'
#' Prints xaringan slides to a PDF file, even complicated slides
#' with panelsets or other html widgets or advanced features.
#' Requires a local installation of Chrome.
#'
#' @param input Path to Rmd or html file of xaringan slides.
#' @param output_file The name of the output file. If using NULL then
#' the output filename will be based on filename for the input file.
#' If a filename is provided, a path to the output file can also be provided.
#' @param delay Seconds of delay between advancing to and printing
#' a new slide.
#' @param include_partial_slides Should partial (continuation) slides be
#' included in the output? If `FALSE`, the default, only the complete slide
#' is included in the PDF.
xaringan_to_pdf <- function(
input,
output_file = NULL,
delay = 1,
include_partial_slides = FALSE
) {
if (!requireNamespace("chromote", quietly = TRUE)) {
stop("`chromote` is required: devtools::install_github('rstudio/chromote')")
}
required_packages <- c("progress", "jsonlite", "pdftools", "digest", "fs")
for (pkg in required_packages) {
if (!requireNamespace(pkg, quietly = TRUE)) {
stop("`", pkg, "` is required: install.packages('", pkg, "')")
}
}
is_url <- grepl("^(ht|f)tp", tolower(input))
if (is.null(output_file)) {
if (is_url) {
output_file <- fs::path_ext_set(fs::path_file(input), "pdf")
} else {
output_file <- fs::path_ext_set(input, "pdf")
}
}
if (!is_url && !grepl("^file://", input)) {
if (!tolower(fs::path_ext(input)) %in% c("htm", "html")) {
stop("`input` must be the HTML version of the slides.")
}
input <- paste0("file://", fs::path_abs(input))
}
b <- chromote::ChromoteSession$new()
on.exit(b$close(), add = TRUE)
b$Page$navigate(input, wait_ = TRUE)
b$Page$loadEventFired()
has_remark <- b$Runtime$evaluate("typeof slideshow !== 'undefined'")$result$value
if (!has_remark) {
stop("Input does not appear to be xaringan slides: ", input)
}
current_slide <- function() {
x <- b$Runtime$evaluate("slideshow.getCurrentSlideIndex()")$result$value
as.integer(x) + 1L
}
slide_is_continuation <- function() {
b$Runtime$evaluate(
"document.querySelector('.remark-visible').matches('.has-continuation')"
)$result$value
}
hash_current_slide <- function() {
digest::digest(b$Runtime$evaluate(
"document.querySelector('.remark-visible').innerHTML"
)$result$value)
}
get_ratio <- function() {
r <- b$Runtime$evaluate('slideshow.getRatio()')$result$value
r <- lapply(strsplit(r, ":"), as.integer)
width <- r[[1]][1]
height <- r[[1]][2]
page_width <- 8/width * width
list(
width = as.integer(908 * width / height),
height = 681L,
page = list(width = page_width, height = page_width * height / width)
)
}
slide_size <- get_ratio()
expected_slides <- as.integer(
b$Runtime$evaluate("slideshow.getSlideCount()")$result$value
)
max_slides <- expected_slides * 4
b$Browser$setWindowBounds(1, bounds = list(
width = slide_size$width,
height = slide_size$height
))
b$Emulation$setEmulatedMedia("print")
b$Runtime$evaluate(paste0(
"let style = document.createElement('style')\n",
"style.innerText = '@media print { ",
".remark-slide-container:not(.remark-visible){ display:none; }",
if (include_partial_slides) " .has-continuation { display: block }",
"}'\n",
"document.head.appendChild(style)"
))
pb <- progress::progress_bar$new(
format = "Slide :slide (:part) [:bar] Eta: :eta",
total = expected_slides
)
idx_slide <- current_slide()
last_hash <- ""
idx_part <- 0L
pdf_files <- c()
for (i in seq_len(max_slides)) {
if (i > 1) {
b$Input$dispatchKeyEvent(
"rawKeyDown",
windowsVirtualKeyCode = 39,
code = "ArrowRight",
key = "ArrowRight",
wait_ = TRUE
)
}
if (current_slide() == idx_slide) {
step <- 0L
idx_part <- idx_part + 1L
} else {
step <- 1L
idx_part <- 1L
}
idx_slide <- current_slide()
pb$tick(step, tokens = list(slide = idx_slide, part = idx_part))
if (!isTRUE(include_partial_slides) && slide_is_continuation()) next
Sys.sleep(delay)
this_hash <- hash_current_slide()
if (identical(last_hash, this_hash)) break
last_hash <- this_hash
pdf_file_promise <- b$Page$printToPDF(
landscape = TRUE,
printBackground = TRUE,
paperWidth = 12,
paperHeight = 9,
marginTop = 0,
marginRight = 0,
marginBottom = 0,
marginLeft = 0,
pageRanges = "1",
preferCSSPageSize = TRUE,
wait_ = FALSE
)$then(function(value) {
filename <- tempfile(fileext = ".pdf")
writeBin(jsonlite::base64_dec(value$data), filename)
filename
})
pdf_files <- c(pdf_files, b$wait_for(pdf_file_promise))
}
pdftools::pdf_combine(pdf_files, output = output_file)
fs::file_delete(pdf_files)
invisible(output_file)
}
@lbelzile

This comment has been minimized.

Copy link

@lbelzile lbelzile commented Feb 1, 2021

Thanks Garrick for this nice feature. I dunno if this is a platform specific-problem, but I get additional blank slides when using the function (e.g., this slide deck gets three empty slides when printed that are not in the html version). I am at lost as to why this happens: did anyone else experience this problem?

@gadenbuie

This comment has been minimized.

Copy link
Owner Author

@gadenbuie gadenbuie commented Feb 1, 2021

@ibelzile it's because of how partial slides are handled — I just pushed a fix that lets you choose between skipping partial slides or including each one. BTW, I've added this function to xaringanBuilder. Give that package a look, it has some nice features that compliment this function!

@lbelzile

This comment has been minimized.

Copy link

@lbelzile lbelzile commented Feb 1, 2021

Thanks for this additional feature, it works flawlessly! Its also nice to know the function has found its way in a package, I will surely have a look.

@simonschoe

This comment has been minimized.

Copy link

@simonschoe simonschoe commented Feb 12, 2021

Hi Garrick, thanks for function, love the simplicity compared to spinning up a docker image for using decktape. However, it appears that emojis can only be rendered in black and white (those stemming from Hadley's emo::ji() function). Any idea how to work around this?

@bttomio

This comment has been minimized.

Copy link

@bttomio bttomio commented Feb 18, 2021

Thanks for this feature, Garrick! I would like to try it to see if it prints correctly your panelset feature. Nonetheless, I am getting this error message:

Error in find_chrome() : 
  `google-chrome` and `chromium-browser` were not found. Try setting the CHROMOTE_CHROME environment variable or adding one of these executables to your PATH.

My guess is that it's failing to find Chromium on Ubuntu, which would be linked to "chromium" instead of "chromium-browser". Could you please take a look at it? Thanks once again!

@lbelzile

This comment has been minimized.

Copy link

@lbelzile lbelzile commented Feb 18, 2021

@bttomio The issue is with the chromote package; there is already a pull request, but it hasn't been merged in the main branch. See rstudio/chromote#27

Until this has been fixed, you can change chrome.R of the chromote package by setting

  path <- Sys.which("google-chrome")
    if (nchar(path) == 0) {
      path <- Sys.which("chromium-browser")
    }
    if (nchar(path) == 0) {
      path <- Sys.which("chromium")
    }
@bttomio

This comment has been minimized.

Copy link

@bttomio bttomio commented Feb 18, 2021

@lbelzile, thanks for your quick answer. I wish I could find chrome.R to change it. Any idea of how to find it in Ubuntu?

@lbelzile

This comment has been minimized.

Copy link

@lbelzile lbelzile commented Feb 18, 2021

It's in the R package chromote, see this directory. You can download the source package, change chrome.R and build the package afterwards yourself.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment