From bfb3cfdc623217d8e5486dba298e71902fd66138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Leite?= Date: Sun, 26 Jul 2026 19:08:56 -0300 Subject: [PATCH 1/2] feat: robust Chrome rendering (persistent session, deterministic font wait, data: URLs) Rework the chromote-based conversion pipeline inspired by production HTML-to-PDF services: - Deterministic readiness: wait for the page load event and document.fonts.ready instead of fixed Sys.sleep() calls, so captures never happen before web fonts render and never over-wait. New `timeout` argument (default 10s); `load_wait` now means extra wait after readiness and defaults to 0. - Persistent Chrome session: a single health-checked ChromoteSession is reused across calls and replaced transparently when dead, removing the ~1-2s session startup per conversion. Closed in .onUnload. - No temp files: pages load via data: URLs (temp file only for very large documents), avoiding file:// path issues on Windows. - Real alpha transparency: background = "transparent" now yields a PNG with a true alpha channel via Emulation.setDefaultBackgroundColorOverride. Adds tests for the new helpers (Chrome tests skipped on CRAN). R CMD check --as-cran: 0 errors, 0 warnings, 0 notes. Co-Authored-By: Claude Fable 5 --- DESCRIPTION | 2 +- NEWS.md | 22 + R/chrome.R | 403 ++++++++++++------ R/zzz.R | 5 + cran-comments.md | 6 + man/batch_svg_to_base64_chrome.Rd | 10 +- man/batch_svg_to_png_chrome.Rd | 10 +- ...ite_svg_html_temp.Rd => build_svg_html.Rd} | 14 +- man/chrome_session.Rd | 21 + man/chrome_session_alive.Rd | 22 + man/close_chrome_session.Rd | 15 + man/convert_svg_with_session.Rd | 15 +- man/load_svg_page.Rd | 28 ++ man/start_chrome_session.Rd | 19 - man/svg_to_pdf_chrome.Rd | 14 +- man/svg_to_png_chrome.Rd | 16 +- man/wait_for_page_ready.Rd | 25 ++ tests/testthat/test-chrome.R | 75 ++++ 18 files changed, 557 insertions(+), 165 deletions(-) rename man/{write_svg_html_temp.Rd => build_svg_html.Rd} (60%) create mode 100644 man/chrome_session.Rd create mode 100644 man/chrome_session_alive.Rd create mode 100644 man/close_chrome_session.Rd create mode 100644 man/load_svg_page.Rd delete mode 100644 man/start_chrome_session.Rd create mode 100644 man/wait_for_page_ready.Rd create mode 100644 tests/testthat/test-chrome.R diff --git a/DESCRIPTION b/DESCRIPTION index 2cce8de..d807ed9 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: cardargus Title: Generate SVG Information Cards with Embedded Fonts and Badges Version: 0.2.5 -Date: 2026-06-29 +Date: 2026-07-26 Authors@R: c( person("Andre", "Leite", email = "leite@castlab.org", role = c("aut", "cre")), person("Marcos", "Wasiliew", email = "marcos.wasilew@gmail.com", role = "aut"), diff --git a/NEWS.md b/NEWS.md index d957001..e162ba5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -8,6 +8,28 @@ * `save_card_for_knitr()` now accepts `format = "pdf"` (in addition to `"svg"` and `"png"`), using Chrome when available and `svg_to_pdf()` otherwise. +## Chrome rendering: more robust, faster, higher quality + +The Chrome-based conversion pipeline (`svg_to_png_chrome()`, +`svg_to_pdf_chrome()`, `batch_svg_to_*_chrome()`) was reworked: + +* **Deterministic readiness wait.** Conversions no longer rely on a fixed + `Sys.sleep()`: the page load event and `document.fonts.ready` are awaited, + so screenshots/PDFs are never captured before web fonts finish rendering, + and no time is wasted over-waiting. The `load_wait` argument now means an + *extra* wait after readiness and defaults to 0; a new `timeout` argument + (default 10s) bounds the readiness wait. +* **Persistent Chrome session.** A single health-checked session is reused + across calls (and replaced transparently if it dies), removing the ~1-2s + session startup previously paid on every conversion. The session is closed + when the package is unloaded. +* **No temp files.** Pages are loaded via `data:` URLs instead of temporary + HTML files and `file://` navigation (a temp file is used only for very + large documents), avoiding path/permission issues on Windows. +* **Real alpha transparency.** With `background = "transparent"`, + `svg_to_png_chrome()` now produces a PNG with a true alpha channel + (previously the backdrop was flattened). + ## Minor improvements * `svg_to_formats(formats = "pdf")` now reuses `svg_to_pdf()` for the vector path diff --git a/R/chrome.R b/R/chrome.R index 1acf2a5..6b0f8d4 100644 --- a/R/chrome.R +++ b/R/chrome.R @@ -326,19 +326,23 @@ find_chrome_in_dir <- function(dir) { # Internal helper: wrap SVG in minimal HTML # ------------------------------------------------------------------------------ -#' Create a temporary HTML file wrapping an SVG +#' Build an HTML document wrapping an SVG +#' +#' @description +#' Returns the HTML as an in-memory string (loaded into Chrome via a +#' `data:` URL), avoiding temp-file I/O and `file://` path issues on Windows. #' #' @param svg_string SVG content as character string. #' @param width_px Width in pixels. If NULL, extracted from SVG. #' @param height_px Height in pixels. If NULL, extracted from SVG. #' @param background Background color (default "transparent"). #' -#' @return A list with path, width, and height. +#' @return A list with html, width, and height. #' @keywords internal -write_svg_html_temp <- function(svg_string, - width_px = NULL, - height_px = NULL, - background = "transparent") { +build_svg_html <- function(svg_string, + width_px = NULL, + height_px = NULL, + background = "transparent") { svg_string <- as.character(svg_string) if (is.null(width_px)) { @@ -385,10 +389,8 @@ write_svg_html_temp <- function(svg_string, width_px, height_px, svg_string ) - - tf <- tempfile(fileext = ".html") - writeLines(html, tf, useBytes = TRUE) - list(path = tf, width = width_px, height = height_px) + + list(html = html, width = width_px, height = height_px) } # ------------------------------------------------------------------------------ @@ -456,65 +458,191 @@ cleanup_chromote_session <- function(session, } -#' Start a new Chrome session safely +# Package-level holder for the persistent Chrome session +.chrome_env <- new.env(parent = emptyenv()) + +#' Check that a ChromoteSession is alive and responsive #' #' @description -#' Creates a new ChromoteSession, optionally closing an existing one first. -#' Includes a small delay after closing to allow Chrome to clean up. +#' Verifies the session is active and answers a trivial CDP command within a +#' short timeout. Used before reusing the persistent session so a dead or hung +#' Chrome is replaced transparently instead of failing the conversion. #' -#' @param old_session Previous session to close (can be NULL). +#' @param b A ChromoteSession object (or NULL). +#' @param timeout Seconds to wait for the health-check command (default 2). #' -#' @return New ChromoteSession object. +#' @return TRUE if the session is usable, FALSE otherwise. #' @keywords internal -start_chrome_session <- function(old_session = NULL) { - if (!is.null(old_session)) { - tryCatch(cleanup_chromote_session(old_session), error = function(e) NULL) +chrome_session_alive <- function(b, timeout = 2) { + if (is.null(b)) return(FALSE) + active <- tryCatch(isTRUE(b$is_active()), error = function(e) FALSE) + if (!active) return(FALSE) + tryCatch({ + r <- b$Runtime$evaluate("1", returnByValue = TRUE, timeout_ = timeout) + !is.null(r$result$value) + }, error = function(e) FALSE) +} + +#' Get (or create) the persistent Chrome session +#' +#' @description +#' Returns a package-level ChromoteSession, creating one on first use and +#' replacing it if it is no longer responsive (health-checked via +#' [chrome_session_alive()]). Reusing one session across conversions avoids +#' the ~1-2s startup cost of a new session per call. +#' +#' @param reset Force-close the current session and start a fresh one. +#' +#' @return A live ChromoteSession object. +#' @keywords internal +chrome_session <- function(reset = FALSE) { + b <- .chrome_env$session + if (!reset && chrome_session_alive(b)) { + return(b) + } + if (!is.null(b)) { + tryCatch(cleanup_chromote_session(b), error = function(e) NULL) + .chrome_env$session <- NULL Sys.sleep(0.3) } - chromote::ChromoteSession$new() + b <- chromote::ChromoteSession$new() + .chrome_env$session <- b + b +} + +#' Close the persistent Chrome session (if any) +#' @return NULL (invisibly). +#' @keywords internal +close_chrome_session <- function() { + b <- .chrome_env$session + if (!is.null(b)) { + tryCatch(cleanup_chromote_session(b), error = function(e) NULL) + .chrome_env$session <- NULL + } + invisible(NULL) } +#' Wait until all fonts on the page are loaded +#' +#' @description +#' Deterministic readiness wait: resolves once `document.fonts.ready` fires +#' (all `@font-face` fonts loaded, including web fonts fetched over the +#' network). This replaces fixed `Sys.sleep()` waits, guaranteeing +#' screenshots/PDFs are never captured before fonts render, while returning +#' as soon as the page is actually ready. Falls back to a short fixed wait +#' (with a warning) if the readiness signal times out. +#' +#' @param b ChromoteSession object. +#' @param timeout Maximum seconds to wait for readiness (default 10). +#' +#' @return NULL (invisibly). +#' @keywords internal +wait_for_page_ready <- function(b, timeout = 10) { + ok <- tryCatch({ + b$Runtime$evaluate( + "document.fonts.ready.then(() => true)", + awaitPromise = TRUE, + returnByValue = TRUE, + timeout_ = timeout + ) + TRUE + }, error = function(e) FALSE) + + if (!ok) { + cli::cli_warn(c( + "!" = "Font readiness signal timed out after {timeout}s; using a fixed wait.", + "i" = "Fonts may not be fully rendered. Consider increasing {.arg timeout}." + )) + Sys.sleep(0.5) + } + invisible(NULL) +} + +#' Load an SVG into a Chrome session and wait until it is rendered +#' +#' @description +#' Sanitizes the SVG, wraps it in HTML, and navigates to it via a `data:` URL +#' (no temp files / file:// URLs; a temp file is used only for very large +#' documents). Blocks until the load event fires and all fonts are loaded. +#' +#' @param b ChromoteSession object. +#' @param svg_content SVG content string. +#' @param background Background color. +#' @param timeout Maximum seconds to wait for load and font readiness. +#' @param extra_wait Additional fixed wait (seconds) after readiness (default 0). +#' +#' @return A list with width and height (CSS px) of the page. +#' @keywords internal +load_svg_page <- function(b, svg_content, background, + timeout = 10, extra_wait = 0) { + svg_content <- sanitize_svg_metadata(svg_content) + page <- build_svg_html(svg_content, background = background) + + url <- paste0( + "data:text/html;base64,", + base64enc::base64encode(charToRaw(page$html)) + ) + # Very large documents: navigate via a temp file instead of a data: URL + if (nchar(url) > 2e6) { + tf <- tempfile(fileext = ".html") + writeLines(page$html, tf, useBytes = TRUE) + url <- paste0("file://", normalizePath(tf, winslash = "/")) + } + + loaded <- b$Page$loadEventFired(wait_ = FALSE, timeout_ = timeout) + b$Page$navigate(url, wait_ = FALSE) + tryCatch(b$wait_for(loaded), error = function(e) { + cli::cli_warn("Page load event timed out after {timeout}s; continuing anyway.") + }) + + wait_for_page_ready(b, timeout = timeout) + if (extra_wait > 0) Sys.sleep(extra_wait) + + list(width = page$width, height = page$height) +} #' Convert a single SVG using an existing Chrome session #' #' @description #' Internal helper that renders an SVG to PNG using an existing Chrome session. -#' Uses fixed-time waiting instead of event-based waiting for reliability. +#' Waits deterministically for load/fonts via [load_svg_page()]. When +#' `background` is transparent, the PNG is captured with a real alpha channel. #' #' @param b ChromoteSession object. #' @param svg_content SVG content string (already sanitized). #' @param scale DPI scale factor. #' @param background Background color. -#' @param load_wait Seconds to wait for page load (default 0.5). +#' @param load_wait Extra fixed wait in seconds after readiness (default 0). +#' @param timeout Maximum seconds to wait for page readiness (default 10). #' #' @return Base64 encoded PNG string. #' @keywords internal -convert_svg_with_session <- function(b, svg_content, scale, background, load_wait = 0.5) { - svg_content <- sanitize_svg_metadata(svg_content) - page <- write_svg_html_temp(svg_content, background = background) - - file_url <- paste0("file://", normalizePath(page$path, winslash = "/")) - - # Navigate without waiting for event (more reliable than loadEventFired) - b$Page$navigate(file_url, wait_ = FALSE) - - # Fixed-time wait for page to load - Sys.sleep(load_wait) - - # Set viewport +convert_svg_with_session <- function(b, svg_content, scale, background, + load_wait = 0, timeout = 10) { + # Transparent background: capture with a real alpha channel + bg_transparent <- tolower(background) %in% c("transparent", "none", "") + if (bg_transparent) { + b$Emulation$setDefaultBackgroundColorOverride( + color = list(r = 0, g = 0, b = 0, a = 0) + ) + } else { + b$Emulation$setDefaultBackgroundColorOverride() + } + + page <- load_svg_page(b, svg_content, background, + timeout = timeout, extra_wait = load_wait) + + # Set viewport at the requested scale b$Emulation$setDeviceMetricsOverride( width = as.integer(page$width), height = as.integer(page$height), deviceScaleFactor = scale, mobile = FALSE ) - - # Small extra delay for rendering - Sys.sleep(0.1) - - # Capture screenshot + + # Capture screenshot (fromSurface produces a fresh frame at the new metrics) shot <- b$Page$captureScreenshot(format = "png", fromSurface = TRUE) - + shot$data } @@ -535,8 +663,16 @@ convert_svg_with_session <- function(b, svg_content, scale, background, load_wai #' so dpi = 300 results in approximately 3.125x scaling. #' @param background Background color for the HTML page (default "transparent"). #' Use "white", "#FFFFFF", etc. for a solid background. -#' @param load_wait Seconds to wait for page to load (default 0.5). -#' Increase if fonts are not rendering correctly. +#' @param load_wait Extra seconds to wait after the page reports ready +#' (default 0). Readiness (fonts loaded, layout painted) is detected +#' automatically; this is only a safety margin for edge cases. +#' @param timeout Maximum seconds to wait for page readiness (default 10). +#' +#' @details +#' The Chrome session is kept alive and reused across calls (with an automatic +#' health check), so repeated conversions avoid the session startup cost. The +#' screenshot is only captured after `document.fonts.ready` resolves, ensuring +#' web fonts are fully rendered. #' #' @return Path to the generated PNG file. #' @export @@ -553,8 +689,9 @@ svg_to_png_chrome <- function(svg_input, output_path = NULL, dpi = 300, background = "transparent", - load_wait = 0.5) { - + load_wait = 0, + timeout = 10) { + if (!requireNamespace("chromote", quietly = TRUE)) { cli::cli_abort(c( "x" = "Package {.pkg chromote} is required.", @@ -567,29 +704,33 @@ svg_to_png_chrome <- function(svg_input, "i" = "Install with: {.code install.packages('base64enc')}" )) } - + # Read SVG content if (is.character(svg_input) && length(svg_input) == 1 && file.exists(svg_input)) { svg_content <- paste(readLines(svg_input, warn = FALSE), collapse = "\n") } else { svg_content <- as.character(svg_input) } - + if (is.null(output_path)) { output_path <- tempfile(fileext = ".png") } ensure_output_dir(output_path) - + scale <- dpi / 96 - - b <- chromote::ChromoteSession$new() - on.exit(cleanup_chromote_session(b), add = TRUE) - - b64 <- convert_svg_with_session(b, svg_content, scale, background, load_wait) - + + # Persistent session; on failure retry once with a fresh session + b64 <- tryCatch({ + convert_svg_with_session(chrome_session(), svg_content, scale, background, + load_wait, timeout) + }, error = function(e) { + convert_svg_with_session(chrome_session(reset = TRUE), svg_content, scale, + background, load_wait, timeout) + }) + raw <- base64enc::base64decode(b64) writeBin(raw, output_path) - + output_path } @@ -604,7 +745,15 @@ svg_to_png_chrome <- function(svg_input, #' @param output_path Output path for the PDF file. #' @param background Background color for the HTML page (default "transparent"). #' @param print_background Whether to include CSS backgrounds in PDF (default TRUE). -#' @param load_wait Seconds to wait for page to load (default 0.5). +#' @param load_wait Extra seconds to wait after the page reports ready +#' (default 0). Readiness (fonts loaded, layout painted) is detected +#' automatically; this is only a safety margin for edge cases. +#' @param timeout Maximum seconds to wait for page readiness (default 10). +#' +#' @details +#' The Chrome session is kept alive and reused across calls (with an automatic +#' health check). The PDF is only generated after `document.fonts.ready` +#' resolves, ensuring web fonts are fully rendered. #' #' @return Path to the generated PDF file. #' @export @@ -620,8 +769,9 @@ svg_to_pdf_chrome <- function(svg_input, output_path, background = "transparent", print_background = TRUE, - load_wait = 0.5) { - + load_wait = 0, + timeout = 10) { + if (!requireNamespace("chromote", quietly = TRUE)) { cli::cli_abort(c( "x" = "Package {.pkg chromote} is required.", @@ -634,43 +784,40 @@ svg_to_pdf_chrome <- function(svg_input, "i" = "Install with: {.code install.packages('base64enc')}" )) } - + # Read SVG content if (is.character(svg_input) && length(svg_input) == 1 && file.exists(svg_input)) { svg_content <- paste(readLines(svg_input, warn = FALSE), collapse = "\n") } else { svg_content <- as.character(svg_input) } - - svg_content <- sanitize_svg_metadata(svg_content) + ensure_output_dir(output_path) - - page <- write_svg_html_temp(svg_content, background = background) - - b <- chromote::ChromoteSession$new() - on.exit(cleanup_chromote_session(b), add = TRUE) - - file_url <- paste0("file://", normalizePath(page$path, winslash = "/")) - - # Navigate without waiting for event - b$Page$navigate(file_url, wait_ = FALSE) - Sys.sleep(load_wait) - - # Generate PDF - pdf <- b$Page$printToPDF( - printBackground = print_background, - marginTop = 0, - marginBottom = 0, - marginLeft = 0, - marginRight = 0, - paperWidth = page$width / 96, - paperHeight = page$height / 96, - preferCSSPageSize = TRUE + + print_svg_pdf <- function(b) { + page <- load_svg_page(b, svg_content, background, + timeout = timeout, extra_wait = load_wait) + b$Page$printToPDF( + printBackground = print_background, + marginTop = 0, + marginBottom = 0, + marginLeft = 0, + marginRight = 0, + paperWidth = page$width / 96, + paperHeight = page$height / 96, + preferCSSPageSize = TRUE + ) + } + + # Persistent session; on failure retry once with a fresh session + pdf <- tryCatch( + print_svg_pdf(chrome_session()), + error = function(e) print_svg_pdf(chrome_session(reset = TRUE)) ) - + raw <- base64enc::base64decode(pdf$data) writeBin(raw, output_path) - + output_path } @@ -687,11 +834,13 @@ svg_to_pdf_chrome <- function(svg_input, #' @param svg_list List of SVG strings or file paths. #' @param dpi Resolution (default 300). #' @param background Background color (default "transparent"). -#' @param load_wait Seconds to wait for each page to load (default 0.5). -#' Increase if conversions are failing. +#' @param load_wait Extra seconds to wait after each page reports ready +#' (default 0). Readiness (fonts loaded, layout painted) is detected +#' automatically; increased automatically on retries. #' @param restart_every Restart Chrome session every N conversions (default 50). #' Helps prevent memory issues and stale connections. #' @param retry_attempts Number of retry attempts on failure (default 3). +#' @param timeout Maximum seconds to wait for page readiness (default 10). #' @param progress Show progress bar (default TRUE). #' #' @return Character vector of base64-encoded PNGs (data URI format). @@ -700,59 +849,63 @@ svg_to_pdf_chrome <- function(svg_input, batch_svg_to_base64_chrome <- function(svg_list, dpi = 300, background = "transparent", - load_wait = 0.5, + load_wait = 0, restart_every = 50, retry_attempts = 3, + timeout = 10, progress = TRUE) { - + if (!requireNamespace("chromote", quietly = TRUE)) { cli::cli_abort(c( "x" = "Package {.pkg chromote} is required.", "i" = "Install with: {.code install.packages('chromote')}" )) } - + if (!requireNamespace("base64enc", quietly = TRUE)) { + cli::cli_abort(c( + "x" = "Package {.pkg base64enc} is required.", + "i" = "Install with: {.code install.packages('base64enc')}" + )) + } + n <- length(svg_list) results <- rep(NA_character_, n) scale <- dpi / 96 - + if (progress) cli::cli_progress_bar("Converting SVGs", total = n) - - b <- start_chrome_session(NULL) - - on.exit({ - tryCatch(cleanup_chromote_session(b), error = function(e) NULL) - }, add = TRUE) - + + b <- chrome_session() + for (i in seq_len(n)) { # Restart session periodically if (i > 1 && ((i - 1) %% restart_every == 0)) { if (progress) cli::cli_alert_info("Restarting Chrome session at item {i}...") - b <- start_chrome_session(b) + b <- chrome_session(reset = TRUE) } - + svg_input <- svg_list[[i]] success <- FALSE attempt <- 0 current_wait <- load_wait - + # Read SVG content once if (is.character(svg_input) && length(svg_input) == 1 && file.exists(svg_input)) { svg_content <- paste(readLines(svg_input, warn = FALSE), collapse = "\n") } else { svg_content <- as.character(svg_input) } - + while (!success && attempt < retry_attempts) { attempt <- attempt + 1 - + result <- tryCatch({ - b64 <- convert_svg_with_session(b, svg_content, scale, background, current_wait) + b64 <- convert_svg_with_session(b, svg_content, scale, background, + current_wait, timeout) list(success = TRUE, data = paste0("data:image/png;base64,", b64)) }, error = function(e) { list(success = FALSE, error = e$message) }) - + if (result$success) { results[[i]] <- result$data success <- TRUE @@ -763,7 +916,7 @@ batch_svg_to_base64_chrome <- function(svg_list, "Item {i} failed (attempt {attempt}/{retry_attempts}). Restarting session..." ) } - b <- start_chrome_session(b) + b <- chrome_session(reset = TRUE) current_wait <- current_wait + 0.3 # Increase wait time on retry } else { if (progress) { @@ -772,7 +925,7 @@ batch_svg_to_base64_chrome <- function(svg_list, } } } - + if (progress) cli::cli_progress_update() } @@ -801,10 +954,12 @@ batch_svg_to_base64_chrome <- function(svg_list, #' @param output_paths Character vector of output paths. If NULL, temp files are created. #' @param dpi Resolution (default 300). #' @param background Background color (default "transparent"). -#' @param load_wait Seconds to wait for each page to load (default 0.5). -#' Increase if conversions are failing. +#' @param load_wait Extra seconds to wait after each page reports ready +#' (default 0). Readiness (fonts loaded, layout painted) is detected +#' automatically; increased automatically on retries. #' @param restart_every Restart Chrome session every N conversions (default 50). #' @param retry_attempts Number of retry attempts on failure (default 3). +#' @param timeout Maximum seconds to wait for page readiness (default 10). #' @param progress Show progress bar (default TRUE). #' #' @return Character vector of output file paths. Returns NA for failed conversions. @@ -813,9 +968,10 @@ batch_svg_to_png_chrome <- function(svg_list, output_paths = NULL, dpi = 300, background = "transparent", - load_wait = 0.5, + load_wait = 0, restart_every = 50, retry_attempts = 3, + timeout = 10, progress = TRUE) { if (!requireNamespace("chromote", quietly = TRUE)) { @@ -845,46 +1001,43 @@ batch_svg_to_png_chrome <- function(svg_list, scale <- dpi / 96 if (progress) cli::cli_progress_bar("Converting SVGs", total = n) - - b <- start_chrome_session(NULL) - - on.exit({ - tryCatch(cleanup_chromote_session(b), error = function(e) NULL) - }, add = TRUE) - + + b <- chrome_session() + for (i in seq_len(n)) { # Restart session periodically if (i > 1 && ((i - 1) %% restart_every == 0)) { if (progress) cli::cli_alert_info("Restarting Chrome session at item {i}...") - b <- start_chrome_session(b) + b <- chrome_session(reset = TRUE) } - + svg_input <- svg_list[[i]] output_path <- output_paths[[i]] success <- FALSE attempt <- 0 current_wait <- load_wait - + # Read SVG content once if (is.character(svg_input) && length(svg_input) == 1 && file.exists(svg_input)) { svg_content <- paste(readLines(svg_input, warn = FALSE), collapse = "\n") } else { svg_content <- as.character(svg_input) } - + while (!success && attempt < retry_attempts) { attempt <- attempt + 1 - + result <- tryCatch({ ensure_output_dir(output_path) - b64 <- convert_svg_with_session(b, svg_content, scale, background, current_wait) + b64 <- convert_svg_with_session(b, svg_content, scale, background, + current_wait, timeout) raw <- base64enc::base64decode(b64) writeBin(raw, output_path) list(success = TRUE, path = output_path) }, error = function(e) { list(success = FALSE, error = e$message) }) - + if (result$success) { results[[i]] <- result$path success <- TRUE @@ -895,7 +1048,7 @@ batch_svg_to_png_chrome <- function(svg_list, "Item {i} failed (attempt {attempt}/{retry_attempts}). Restarting session..." ) } - b <- start_chrome_session(b) + b <- chrome_session(reset = TRUE) current_wait <- current_wait + 0.3 } else { if (progress) { @@ -904,7 +1057,7 @@ batch_svg_to_png_chrome <- function(svg_list, } } } - + if (progress) cli::cli_progress_update() } diff --git a/R/zzz.R b/R/zzz.R index c24ab18..08b291e 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -41,3 +41,8 @@ }) } } + +.onUnload <- function(libpath) { + # Close the persistent Chrome session, if one was created + tryCatch(close_chrome_session(), error = function(e) NULL) +} diff --git a/cran-comments.md b/cran-comments.md index 9024d24..726a0c4 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -15,6 +15,12 @@ This is a minor feature release. * Minor fixes: `svg_card()` sizes badges using `value_fontsize` (no longer a hard-coded `10`); `is_light_color()` reports an unknown color name via `cli` instead of a raw `col2rgb()` error. +* The Chrome-based conversion pipeline (via `chromote`, in Suggests) is more + robust: conversions wait deterministically for `document.fonts.ready` + instead of a fixed sleep, a single health-checked Chrome session is reused + across calls (closed in `.onUnload`), and pages are loaded via `data:` URLs + instead of temporary files. Chrome rendering tests are skipped on CRAN + (`skip_on_cran()`); no examples or tests launch Chrome during checks. ## R CMD check results diff --git a/man/batch_svg_to_base64_chrome.Rd b/man/batch_svg_to_base64_chrome.Rd index e69b48b..9f494b5 100644 --- a/man/batch_svg_to_base64_chrome.Rd +++ b/man/batch_svg_to_base64_chrome.Rd @@ -8,9 +8,10 @@ batch_svg_to_base64_chrome( svg_list, dpi = 300, background = "transparent", - load_wait = 0.5, + load_wait = 0, restart_every = 50, retry_attempts = 3, + timeout = 10, progress = TRUE ) } @@ -21,14 +22,17 @@ batch_svg_to_base64_chrome( \item{background}{Background color (default "transparent").} -\item{load_wait}{Seconds to wait for each page to load (default 0.5). -Increase if conversions are failing.} +\item{load_wait}{Extra seconds to wait after each page reports ready +(default 0). Readiness (fonts loaded, layout painted) is detected +automatically; increased automatically on retries.} \item{restart_every}{Restart Chrome session every N conversions (default 50). Helps prevent memory issues and stale connections.} \item{retry_attempts}{Number of retry attempts on failure (default 3).} +\item{timeout}{Maximum seconds to wait for page readiness (default 10).} + \item{progress}{Show progress bar (default TRUE).} } \value{ diff --git a/man/batch_svg_to_png_chrome.Rd b/man/batch_svg_to_png_chrome.Rd index abed198..cde1a33 100644 --- a/man/batch_svg_to_png_chrome.Rd +++ b/man/batch_svg_to_png_chrome.Rd @@ -9,9 +9,10 @@ batch_svg_to_png_chrome( output_paths = NULL, dpi = 300, background = "transparent", - load_wait = 0.5, + load_wait = 0, restart_every = 50, retry_attempts = 3, + timeout = 10, progress = TRUE ) } @@ -24,13 +25,16 @@ batch_svg_to_png_chrome( \item{background}{Background color (default "transparent").} -\item{load_wait}{Seconds to wait for each page to load (default 0.5). -Increase if conversions are failing.} +\item{load_wait}{Extra seconds to wait after each page reports ready +(default 0). Readiness (fonts loaded, layout painted) is detected +automatically; increased automatically on retries.} \item{restart_every}{Restart Chrome session every N conversions (default 50).} \item{retry_attempts}{Number of retry attempts on failure (default 3).} +\item{timeout}{Maximum seconds to wait for page readiness (default 10).} + \item{progress}{Show progress bar (default TRUE).} } \value{ diff --git a/man/write_svg_html_temp.Rd b/man/build_svg_html.Rd similarity index 60% rename from man/write_svg_html_temp.Rd rename to man/build_svg_html.Rd index af8f3c0..736af96 100644 --- a/man/write_svg_html_temp.Rd +++ b/man/build_svg_html.Rd @@ -1,10 +1,10 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/chrome.R -\name{write_svg_html_temp} -\alias{write_svg_html_temp} -\title{Create a temporary HTML file wrapping an SVG} +\name{build_svg_html} +\alias{build_svg_html} +\title{Build an HTML document wrapping an SVG} \usage{ -write_svg_html_temp( +build_svg_html( svg_string, width_px = NULL, height_px = NULL, @@ -21,9 +21,11 @@ write_svg_html_temp( \item{background}{Background color (default "transparent").} } \value{ -A list with path, width, and height. +A list with html, width, and height. } \description{ -Create a temporary HTML file wrapping an SVG +Returns the HTML as an in-memory string (injected into Chrome via +\code{Page.setDocumentContent}), avoiding temp-file I/O and \verb{file://} path +issues on Windows. } \keyword{internal} diff --git a/man/chrome_session.Rd b/man/chrome_session.Rd new file mode 100644 index 0000000..c13f0a9 --- /dev/null +++ b/man/chrome_session.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/chrome.R +\name{chrome_session} +\alias{chrome_session} +\title{Get (or create) the persistent Chrome session} +\usage{ +chrome_session(reset = FALSE) +} +\arguments{ +\item{reset}{Force-close the current session and start a fresh one.} +} +\value{ +A live ChromoteSession object. +} +\description{ +Returns a package-level ChromoteSession, creating one on first use and +replacing it if it is no longer responsive (health-checked via +\code{\link[=chrome_session_alive]{chrome_session_alive()}}). Reusing one session across conversions avoids +the ~1-2s startup cost of a new session per call. +} +\keyword{internal} diff --git a/man/chrome_session_alive.Rd b/man/chrome_session_alive.Rd new file mode 100644 index 0000000..5f79246 --- /dev/null +++ b/man/chrome_session_alive.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/chrome.R +\name{chrome_session_alive} +\alias{chrome_session_alive} +\title{Check that a ChromoteSession is alive and responsive} +\usage{ +chrome_session_alive(b, timeout = 2) +} +\arguments{ +\item{b}{A ChromoteSession object (or NULL).} + +\item{timeout}{Seconds to wait for the health-check command (default 2).} +} +\value{ +TRUE if the session is usable, FALSE otherwise. +} +\description{ +Verifies the session is active and answers a trivial CDP command within a +short timeout. Used before reusing the persistent session so a dead or hung +Chrome is replaced transparently instead of failing the conversion. +} +\keyword{internal} diff --git a/man/close_chrome_session.Rd b/man/close_chrome_session.Rd new file mode 100644 index 0000000..36e688d --- /dev/null +++ b/man/close_chrome_session.Rd @@ -0,0 +1,15 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/chrome.R +\name{close_chrome_session} +\alias{close_chrome_session} +\title{Close the persistent Chrome session (if any)} +\usage{ +close_chrome_session() +} +\value{ +NULL (invisibly). +} +\description{ +Close the persistent Chrome session (if any) +} +\keyword{internal} diff --git a/man/convert_svg_with_session.Rd b/man/convert_svg_with_session.Rd index 2addf2d..6f0ac52 100644 --- a/man/convert_svg_with_session.Rd +++ b/man/convert_svg_with_session.Rd @@ -4,7 +4,14 @@ \alias{convert_svg_with_session} \title{Convert a single SVG using an existing Chrome session} \usage{ -convert_svg_with_session(b, svg_content, scale, background, load_wait = 0.5) +convert_svg_with_session( + b, + svg_content, + scale, + background, + load_wait = 0, + timeout = 10 +) } \arguments{ \item{b}{ChromoteSession object.} @@ -15,13 +22,15 @@ convert_svg_with_session(b, svg_content, scale, background, load_wait = 0.5) \item{background}{Background color.} -\item{load_wait}{Seconds to wait for page load (default 0.5).} +\item{load_wait}{Extra fixed wait in seconds after readiness (default 0).} + +\item{timeout}{Maximum seconds to wait for page readiness (default 10).} } \value{ Base64 encoded PNG string. } \description{ Internal helper that renders an SVG to PNG using an existing Chrome session. -Uses fixed-time waiting instead of event-based waiting for reliability. +Waits deterministically for fonts/paint via \code{\link[=wait_for_page_ready]{wait_for_page_ready()}}. } \keyword{internal} diff --git a/man/load_svg_page.Rd b/man/load_svg_page.Rd new file mode 100644 index 0000000..2329e41 --- /dev/null +++ b/man/load_svg_page.Rd @@ -0,0 +1,28 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/chrome.R +\name{load_svg_page} +\alias{load_svg_page} +\title{Load an SVG into a Chrome session and wait until it is rendered} +\usage{ +load_svg_page(b, svg_content, background, timeout = 10, extra_wait = 0) +} +\arguments{ +\item{b}{ChromoteSession object.} + +\item{svg_content}{SVG content string.} + +\item{background}{Background color.} + +\item{timeout}{Maximum seconds to wait for page readiness.} + +\item{extra_wait}{Additional fixed wait (seconds) after readiness (default 0).} +} +\value{ +A list with width and height (CSS px) of the page. +} +\description{ +Sanitizes the SVG, wraps it in HTML, injects it via \code{Page.setDocumentContent} +(no temp files / file:// URLs), and blocks until fonts are loaded and the +page has painted. +} +\keyword{internal} diff --git a/man/start_chrome_session.Rd b/man/start_chrome_session.Rd deleted file mode 100644 index df3d437..0000000 --- a/man/start_chrome_session.Rd +++ /dev/null @@ -1,19 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/chrome.R -\name{start_chrome_session} -\alias{start_chrome_session} -\title{Start a new Chrome session safely} -\usage{ -start_chrome_session(old_session = NULL) -} -\arguments{ -\item{old_session}{Previous session to close (can be NULL).} -} -\value{ -New ChromoteSession object. -} -\description{ -Creates a new ChromoteSession, optionally closing an existing one first. -Includes a small delay after closing to allow Chrome to clean up. -} -\keyword{internal} diff --git a/man/svg_to_pdf_chrome.Rd b/man/svg_to_pdf_chrome.Rd index 8d017be..84e8a88 100644 --- a/man/svg_to_pdf_chrome.Rd +++ b/man/svg_to_pdf_chrome.Rd @@ -9,7 +9,8 @@ svg_to_pdf_chrome( output_path, background = "transparent", print_background = TRUE, - load_wait = 0.5 + load_wait = 0, + timeout = 10 ) } \arguments{ @@ -21,7 +22,11 @@ svg_to_pdf_chrome( \item{print_background}{Whether to include CSS backgrounds in PDF (default TRUE).} -\item{load_wait}{Seconds to wait for page to load (default 0.5).} +\item{load_wait}{Extra seconds to wait after the page reports ready +(default 0). Readiness (fonts loaded, layout painted) is detected +automatically; this is only a safety margin for edge cases.} + +\item{timeout}{Maximum seconds to wait for page readiness (default 10).} } \value{ Path to the generated PDF file. @@ -30,6 +35,11 @@ Path to the generated PDF file. Renders an SVG to PDF using headless Chrome via the chromote package. This method produces vector PDFs with perfect font rendering. } +\details{ +The Chrome session is kept alive and reused across calls (with an automatic +health check). The PDF is only generated after \code{document.fonts.ready} +resolves, ensuring web fonts are fully rendered. +} \examples{ \dontrun{ svg <- svg_card("FAR", list(), list()) diff --git a/man/svg_to_png_chrome.Rd b/man/svg_to_png_chrome.Rd index 124e453..8b48e78 100644 --- a/man/svg_to_png_chrome.Rd +++ b/man/svg_to_png_chrome.Rd @@ -9,7 +9,8 @@ svg_to_png_chrome( output_path = NULL, dpi = 300, background = "transparent", - load_wait = 0.5 + load_wait = 0, + timeout = 10 ) } \arguments{ @@ -23,8 +24,11 @@ so dpi = 300 results in approximately 3.125x scaling.} \item{background}{Background color for the HTML page (default "transparent"). Use "white", "#FFFFFF", etc. for a solid background.} -\item{load_wait}{Seconds to wait for page to load (default 0.5). -Increase if fonts are not rendering correctly.} +\item{load_wait}{Extra seconds to wait after the page reports ready +(default 0). Readiness (fonts loaded, layout painted) is detected +automatically; this is only a safety margin for edge cases.} + +\item{timeout}{Maximum seconds to wait for page readiness (default 10).} } \value{ Path to the generated PNG file. @@ -34,6 +38,12 @@ Renders an SVG to PNG using headless Chrome via the chromote package. This method provides superior font rendering compared to librsvg/ImageMagick, as Chrome properly handles @font-face rules, web fonts, and CSS features. } +\details{ +The Chrome session is kept alive and reused across calls (with an automatic +health check), so repeated conversions avoid the session startup cost. The +screenshot is only captured after \code{document.fonts.ready} resolves, ensuring +web fonts are fully rendered. +} \examples{ svg <- svg_card("FAR", list(), list()) file_name <- tempfile(fileext = ".png") diff --git a/man/wait_for_page_ready.Rd b/man/wait_for_page_ready.Rd new file mode 100644 index 0000000..4be2c4f --- /dev/null +++ b/man/wait_for_page_ready.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/chrome.R +\name{wait_for_page_ready} +\alias{wait_for_page_ready} +\title{Wait until the page is fully rendered (fonts loaded, layout painted)} +\usage{ +wait_for_page_ready(b, timeout = 10) +} +\arguments{ +\item{b}{ChromoteSession object.} + +\item{timeout}{Maximum seconds to wait for readiness (default 10).} +} +\value{ +NULL (invisibly). +} +\description{ +Deterministic readiness wait: resolves once \code{document.fonts.ready} fires +(all \verb{@font-face} fonts loaded) and two animation frames have painted. +This replaces fixed \code{Sys.sleep()} waits, guaranteeing screenshots/PDFs are +never captured before web fonts render, while returning as soon as the +page is actually ready. Falls back to a short fixed wait (with a warning) +if the readiness signal times out. +} +\keyword{internal} diff --git a/tests/testthat/test-chrome.R b/tests/testthat/test-chrome.R new file mode 100644 index 0000000..b911818 --- /dev/null +++ b/tests/testthat/test-chrome.R @@ -0,0 +1,75 @@ +# Tests for chrome.R helpers. +# Pure helpers are tested everywhere; rendering tests need Chrome and are +# skipped on CRAN. + +test_that("build_svg_html wraps the SVG and extracts dimensions", { + svg <- '' + page <- build_svg_html(svg) + + expect_type(page$html, "character") + expect_equal(page$width, 500L) + expect_equal(page$height, 130L) + expect_match(page$html, "width: 500px", fixed = TRUE) + expect_match(page$html, "height: 130px", fixed = TRUE) + expect_match(page$html, "", fixed = TRUE) +}) + +test_that("build_svg_html falls back to default dimensions", { + page <- build_svg_html('') + expect_equal(page$width, 800L) + expect_equal(page$height, 500L) +}) + +test_that("build_svg_html applies the requested background", { + svg <- '' + page <- build_svg_html(svg, background = "white") + expect_match(page$html, "background: white", fixed = TRUE) +}) + +test_that("chrome_session_alive is FALSE for NULL", { + expect_false(chrome_session_alive(NULL)) +}) + +test_that("svg_to_png_chrome renders a card deterministically", { + skip_on_cran() + skip_if_not_installed("chromote") + skip_if_not_installed("base64enc") + skip_if_not(chrome_available(), "Chrome not available") + + svg <- svg_card("Chrome test") + p1 <- svg_to_png_chrome(svg, tempfile(fileext = ".png"), dpi = 96) + p2 <- svg_to_png_chrome(svg, tempfile(fileext = ".png"), dpi = 96) + + expect_true(file.exists(p1)) + expect_gt(file.size(p1), 1000) + # Persistent session: repeated conversions give byte-identical output + expect_identical(readBin(p1, "raw", file.size(p1)), + readBin(p2, "raw", file.size(p2))) +}) + +test_that("svg_to_png_chrome self-heals after a dead session", { + skip_on_cran() + skip_if_not_installed("chromote") + skip_if_not_installed("base64enc") + skip_if_not(chrome_available(), "Chrome not available") + + svg <- svg_card("Heal test") + b <- chrome_session() + b$close() + + p <- svg_to_png_chrome(svg, tempfile(fileext = ".png"), dpi = 96) + expect_true(file.exists(p)) + expect_gt(file.size(p), 1000) +}) + +test_that("svg_to_pdf_chrome produces a PDF", { + skip_on_cran() + skip_if_not_installed("chromote") + skip_if_not_installed("base64enc") + skip_if_not(chrome_available(), "Chrome not available") + + svg <- svg_card("PDF test") + p <- svg_to_pdf_chrome(svg, tempfile(fileext = ".pdf")) + expect_true(file.exists(p)) + expect_identical(readBin(p, "raw", 4), charToRaw("%PDF")) +}) From de09a1dc18739ca53d1b1c0b29419bc040191de0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Leite?= Date: Sun, 26 Jul 2026 19:25:43 -0300 Subject: [PATCH 2/2] fix: force UTF-8 bytes in data: URL encoding The wrapper HTML declares charset=utf-8, so the base64-encoded bytes must be UTF-8 regardless of the session's native encoding (relevant on Windows with R < 4.2). Co-Authored-By: Claude Fable 5 --- R/chrome.R | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/R/chrome.R b/R/chrome.R index 6b0f8d4..3c81acd 100644 --- a/R/chrome.R +++ b/R/chrome.R @@ -578,9 +578,11 @@ load_svg_page <- function(b, svg_content, background, svg_content <- sanitize_svg_metadata(svg_content) page <- build_svg_html(svg_content, background = background) + # enc2utf8: the HTML declares charset=utf-8, so the bytes must be UTF-8 + # regardless of the session's native encoding url <- paste0( "data:text/html;base64,", - base64enc::base64encode(charToRaw(page$html)) + base64enc::base64encode(charToRaw(enc2utf8(page$html))) ) # Very large documents: navigate via a temp file instead of a data: URL if (nchar(url) > 2e6) {