From badf4d80f883bca738f606715dd8fe0ad025c3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Leite?= Date: Mon, 29 Jun 2026 08:07:54 -0300 Subject: [PATCH] feat: add svg_to_pdf() and apply audit fixes; bump to 0.2.5 Export cards to vector PDF and resolve open audit items. - svg_to_pdf(): vector PDF via rsvg::rsvg_pdf(), mirroring svg_to_png() (sanitizes + embeds WOFF2 fonts). save_card_for_knitr() gains format="pdf"; svg_to_formats() reuses svg_to_pdf() and warns on the rasterized magick fallback. (#17) - svg_card(): size badges with value_fontsize instead of a hard-coded 10, preventing clipped/misaligned badge text. (#18) - Move magick and rsvg from Imports to Suggests (only needed for raster/PDF, already guarded by requireNamespace); guard the affected examples. (#19) - is_light_color(): report unknown color names via cli instead of a raw col2rgb() error. (#20) - Tests for svg_to_pdf() and the is_light_color() name path; DESCRIPTION/NEWS bumped to 0.2.5. R CMD check --as-cran: 0/0/0. Closes #17 #18 #19 #20 Co-Authored-By: Claude Opus 4.8 (1M context) --- DESCRIPTION | 10 ++-- NAMESPACE | 1 + NEWS.md | 27 +++++++++ R/cards.R | 4 +- R/conversion.R | 97 +++++++++++++++++++++++++++++--- R/knitr.R | 28 +++++---- R/utils.R | 7 ++- man/cardargus-package.Rd | 2 +- man/save_card_for_knitr.Rd | 6 +- man/svg_to_pdf.Rd | 36 ++++++++++++ man/svg_to_png.Rd | 11 ++-- tests/testthat/test-conversion.R | 21 +++++++ tests/testthat/test-utils.R | 4 ++ 13 files changed, 219 insertions(+), 35 deletions(-) create mode 100644 man/svg_to_pdf.Rd diff --git a/DESCRIPTION b/DESCRIPTION index e984f52..9ee6c2c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: cardargus Title: Generate SVG Information Cards with Embedded Fonts and Badges -Version: 0.2.4 -Date: 2026-06-14 +Version: 0.2.5 +Date: 2026-06-29 Authors@R: c( person("Andre", "Leite", email = "leite@castlab.org", role = c("aut", "cre")), person("Hugo", "Vasconcelos", email = "hugo.vasconcelos@ufpe.br", role = "aut"), @@ -9,7 +9,7 @@ Authors@R: c( Description: Create self-contained SVG information cards with embedded 'Google Fonts', shields-style badges, and custom logos. Cards are fully portable SVG files ideal for dashboards, reports, and web applications. - Includes functions to export cards to PNG format and display them in + Includes functions to export cards to PNG and PDF formats and display them in 'R Markdown' and 'Quarto' documents. License: MIT + file LICENSE Encoding: UTF-8 @@ -22,8 +22,6 @@ Imports: cli, digest, gdtools, - magick, - rsvg, later Suggests: base64enc, @@ -32,7 +30,9 @@ Suggests: gfonts, htmltools, knitr, + magick, rmarkdown, + rsvg, showtext, sysfonts, systemfonts, diff --git a/NAMESPACE b/NAMESPACE index efd72de..c013cfa 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -36,6 +36,7 @@ export(save_svg) export(setup_fonts) export(svg_card) export(svg_to_formats) +export(svg_to_pdf) export(svg_to_pdf_chrome) export(svg_to_png) export(svg_to_png_chrome) diff --git a/NEWS.md b/NEWS.md index 59c19cf..d957001 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,30 @@ +# cardargus 0.2.5 + +## New features + +* `svg_to_pdf()` exports a card to a **vector** PDF via `rsvg::rsvg_pdf()`, + mirroring `svg_to_png()` (sanitizes the SVG and embeds WOFF2 fonts). For + Chrome-based rendering, `svg_to_pdf_chrome()` remains available (#17). +* `save_card_for_knitr()` now accepts `format = "pdf"` (in addition to `"svg"` + and `"png"`), using Chrome when available and `svg_to_pdf()` otherwise. + +## Minor improvements + +* `svg_to_formats(formats = "pdf")` now reuses `svg_to_pdf()` for the vector path + and warns explicitly when it falls back to a rasterized PDF via `magick`. + +## Bug fixes + +* `magick` and `rsvg` moved from `Imports` to `Suggests`: they are only needed + for raster/PDF export, which is already guarded by `requireNamespace()`. This + lightens the install footprint and matches the package's "heavy deps in + Suggests" convention (#19). +* `svg_card()` now sizes badges using `value_fontsize` instead of a hard-coded + `10`, preventing clipped/misaligned badge text when `value_fontsize` differs + from 10 (#18). +* `is_light_color()` now reports an unknown color name with a clear `cli` error + instead of letting `col2rgb()` raise a raw base error (#20). + # cardargus 0.2.4 ## CRAN policy fix diff --git a/R/cards.R b/R/cards.R index 1205238..a0363cc 100644 --- a/R/cards.R +++ b/R/cards.R @@ -190,8 +190,8 @@ svg_card <- function(title = "FAR", # First pass: calculate max height for uniform badges max_badge_height <- 0 for (badge in badges_data) { - metrics_label <- gdtools::str_metrics(badge$label, fontname = font, fontsize = 10) - metrics_value <- gdtools::str_metrics(badge$value, fontname = font, fontsize = 10) + metrics_label <- gdtools::str_metrics(badge$label, fontname = font, fontsize = value_fontsize) + metrics_value <- gdtools::str_metrics(badge$value, fontname = font, fontsize = value_fontsize) height_label <- as.numeric(metrics_label["ascent"]) + as.numeric(metrics_label["descent"]) height_value <- as.numeric(metrics_value["ascent"]) + as.numeric(metrics_value["descent"]) badge_h <- max(height_label, height_value) * 2 diff --git a/R/conversion.R b/R/conversion.R index 0045eb0..fcfbd46 100644 --- a/R/conversion.R +++ b/R/conversion.R @@ -164,10 +164,13 @@ save_svg <- function(svg_content, output_path) { #' @export #' #' @examples -#' svg <- svg_card("FAR", list(), list()) -#' file_name <- tempfile(fileext = ".png") -#' png_path <- svg_to_png(svg, file_name, dpi = 300) -#' png_path <- svg_to_png(svg, file_name, dpi = 300, background = "white") +#' if (requireNamespace("rsvg", quietly = TRUE) || +#' requireNamespace("magick", quietly = TRUE)) { +#' svg <- svg_card("FAR", list(), list()) +#' file_name <- tempfile(fileext = ".png") +#' png_path <- svg_to_png(svg, file_name, dpi = 300) +#' png_path <- svg_to_png(svg, file_name, dpi = 300, background = "white") +#' } svg_to_png <- function(svg_input, output_path = NULL, width = NULL, @@ -257,6 +260,78 @@ svg_to_png <- function(svg_input, cli::cli_abort("Failed to convert SVG to PNG (no working renderer found).") } +#' Convert SVG to PDF +#' +#' @description +#' Convert an SVG string or SVG file path to a **vector** PDF. As with +#' [svg_to_png()], the SVG is sanitized and the required WOFF2 fonts are embedded +#' (downloaded on demand into a user cache) so text renders consistently. +#' Conversion uses \pkg{rsvg} ([rsvg::rsvg_pdf()]), preserving vector text. +#' +#' For headless-Chrome rendering (often best for web fonts), see +#' [svg_to_pdf_chrome()]. +#' +#' @param svg_input SVG string or path to an SVG file. +#' @param output_path Output path for the PDF file (optional; a temp file is used if NULL). +#' @param width Output width in pixels (NULL to infer from the SVG). +#' @param height Output height in pixels (NULL to infer from the SVG). +#' +#' @return Path to the generated PDF file. +#' @export +#' +#' @examples +#' if (requireNamespace("rsvg", quietly = TRUE)) { +#' svg <- svg_card("FAR", list(), list()) +#' file_name <- tempfile(fileext = ".pdf") +#' pdf_path <- svg_to_pdf(svg, file_name) +#' } +svg_to_pdf <- function(svg_input, + output_path = NULL, + width = NULL, + height = NULL) { + + if (!requireNamespace("rsvg", quietly = TRUE)) { + cli::cli_abort(c( + "x" = "Vector PDF conversion requires the {.pkg rsvg} package.", + "i" = "Install with: {.code install.packages('rsvg')}", + "i" = "For Chrome-based PDF rendering, see {.fn svg_to_pdf_chrome}." + )) + } + + # Read SVG (file path vs string) + 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) + } + + # Sanitize + embed fonts (download/cache if needed) + svg_content <- prepare_svg_for_raster(svg_content) + + # Output path + if (is.null(output_path)) output_path <- tempfile(fileext = ".pdf") + ensure_output_dir(output_path) + + # Infer dimensions from the SVG when not supplied (CSS px reference) + if (is.null(width)) { + w0 <- parse_svg_root_dim(svg_content, "width") + if (!is.na(w0)) width <- max(1L, round(w0)) + } + if (is.null(height)) { + h0 <- parse_svg_root_dim(svg_content, "height") + if (!is.na(h0)) height <- max(1L, round(h0)) + } + + svg_file <- write_svg_tempfile(svg_content) + + tryCatch( + rsvg::rsvg_pdf(svg_file, file = output_path, width = width, height = height), + error = function(e) cli::cli_abort("Failed to convert SVG to PDF: {e$message}") + ) + + output_path +} + #' Convert SVG to multiple formats #' #' @description @@ -308,16 +383,22 @@ svg_to_formats <- function(svg_input, } else if (identical(fmt, "pdf")) { ensure_output_dir(output_path) - + if (requireNamespace("rsvg", quietly = TRUE)) { - svg_file <- write_svg_tempfile(svg_prepared) + # Vector PDF (text preserved). Content is already prepared, but + # svg_to_pdf() re-runs prepare_svg_for_raster() idempotently. tryCatch({ - rsvg::rsvg_pdf(svg_file, file = output_path) - results$pdf <- output_path + results$pdf <- svg_to_pdf(svg_prepared, output_path) }, error = function(e) { cli::cli_warn("Failed to create PDF via rsvg: {e$message}") }) } else if (requireNamespace("magick", quietly = TRUE)) { + # NOTE: magick rasterizes the SVG, so this produces a *raster* PDF + # (vector text is lost). Install {rsvg} for a vector PDF. + cli::cli_warn(c( + "!" = "Creating a rasterized PDF via {.pkg magick} (vector text lost).", + "i" = "Install {.pkg rsvg} for a vector PDF." + )) svg_file <- write_svg_tempfile(svg_prepared) tryCatch({ img <- magick::image_read_svg(svg_file) diff --git a/R/knitr.R b/R/knitr.R index 841d1bb..b8f0b30 100644 --- a/R/knitr.R +++ b/R/knitr.R @@ -125,15 +125,15 @@ include_card_png <- function(svg_string, #' #' @param svg_string SVG string from `svg_card()`. #' @param filename Output filename (without extension). -#' @param format Output format: `"svg"` or `"png"`. +#' @param format Output format: `"svg"`, `"png"`, or `"pdf"`. #' @param dpi Rasterization DPI for PNG (default 300). #' @param dir Output directory (defaults to knitr figure directory or tempdir()). -#' @param engine Rendering engine for PNG: `"auto"`, `"chrome"`, or `"rsvg"`. +#' @param engine Rendering engine for PNG/PDF: `"auto"`, `"chrome"`, or `"rsvg"`. #' @return Path to the saved file. #' @export save_card_for_knitr <- function(svg_string, filename = "card", - format = c("svg", "png"), + format = c("svg", "png", "pdf"), dpi = 300, dir = NULL, engine = c("auto", "chrome", "rsvg")) { @@ -160,9 +160,9 @@ save_card_for_knitr <- function(svg_string, if (format == "svg") { save_svg(svg_string, output_path) } else { - # Determine which engine to use + # Determine which engine to use (relevant for png/pdf) use_chrome <- FALSE - + if (engine == "chrome") { if (chrome_available()) { use_chrome <- TRUE @@ -173,14 +173,22 @@ save_card_for_knitr <- function(svg_string, # Auto: prefer Chrome if available (better font rendering) use_chrome <- chrome_available() } - - if (use_chrome) { - svg_to_png_chrome(svg_string, output_path, dpi = dpi, background = "transparent") + + if (format == "pdf") { + if (use_chrome) { + svg_to_pdf_chrome(svg_string, output_path, background = "transparent") + } else { + svg_to_pdf(svg_string, output_path) + } } else { - svg_to_png(svg_string, output_path, dpi = dpi, background = "transparent") + if (use_chrome) { + svg_to_png_chrome(svg_string, output_path, dpi = dpi, background = "transparent") + } else { + svg_to_png(svg_string, output_path, dpi = dpi, background = "transparent") + } } } - + output_path } diff --git a/R/utils.R b/R/utils.R index c2e15a5..5ada623 100644 --- a/R/utils.R +++ b/R/utils.R @@ -31,8 +31,11 @@ is_light_color <- function(color) { cli::cli_abort("{.arg color} is not a valid hex color: {.val {color}}.") } } else { - # Try to get RGB from color name - rgb_vals <- col2rgb(color) + # Try to get RGB from color name (col2rgb() errors on unknown names) + rgb_vals <- tryCatch(col2rgb(color), error = function(e) NULL) + if (is.null(rgb_vals)) { + cli::cli_abort("{.arg color} is not a valid color: {.val {color}}.") + } r <- rgb_vals[1] g <- rgb_vals[2] b <- rgb_vals[3] diff --git a/man/cardargus-package.Rd b/man/cardargus-package.Rd index a71bfb5..847708c 100644 --- a/man/cardargus-package.Rd +++ b/man/cardargus-package.Rd @@ -8,7 +8,7 @@ \description{ \if{html}{\figure{logo.svg}{options: style='float: right' alt='logo' width='120'}} -Create self-contained SVG information cards with embedded 'Google Fonts', shields-style badges, and custom logos. Cards are fully portable SVG files ideal for dashboards, reports, and web applications. Includes functions to export cards to PNG format and display them in 'R Markdown' and 'Quarto' documents. +Create self-contained SVG information cards with embedded 'Google Fonts', shields-style badges, and custom logos. Cards are fully portable SVG files ideal for dashboards, reports, and web applications. Includes functions to export cards to PNG and PDF formats and display them in 'R Markdown' and 'Quarto' documents. } \seealso{ Useful links: diff --git a/man/save_card_for_knitr.Rd b/man/save_card_for_knitr.Rd index 50828c8..abf469e 100644 --- a/man/save_card_for_knitr.Rd +++ b/man/save_card_for_knitr.Rd @@ -7,7 +7,7 @@ save_card_for_knitr( svg_string, filename = "card", - format = c("svg", "png"), + format = c("svg", "png", "pdf"), dpi = 300, dir = NULL, engine = c("auto", "chrome", "rsvg") @@ -18,13 +18,13 @@ save_card_for_knitr( \item{filename}{Output filename (without extension).} -\item{format}{Output format: \code{"svg"} or \code{"png"}.} +\item{format}{Output format: \code{"svg"}, \code{"png"}, or \code{"pdf"}.} \item{dpi}{Rasterization DPI for PNG (default 300).} \item{dir}{Output directory (defaults to knitr figure directory or tempdir()).} -\item{engine}{Rendering engine for PNG: \code{"auto"}, \code{"chrome"}, or \code{"rsvg"}.} +\item{engine}{Rendering engine for PNG/PDF: \code{"auto"}, \code{"chrome"}, or \code{"rsvg"}.} } \value{ Path to the saved file. diff --git a/man/svg_to_pdf.Rd b/man/svg_to_pdf.Rd new file mode 100644 index 0000000..60acae1 --- /dev/null +++ b/man/svg_to_pdf.Rd @@ -0,0 +1,36 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/conversion.R +\name{svg_to_pdf} +\alias{svg_to_pdf} +\title{Convert SVG to PDF} +\usage{ +svg_to_pdf(svg_input, output_path = NULL, width = NULL, height = NULL) +} +\arguments{ +\item{svg_input}{SVG string or path to an SVG file.} + +\item{output_path}{Output path for the PDF file (optional; a temp file is used if NULL).} + +\item{width}{Output width in pixels (NULL to infer from the SVG).} + +\item{height}{Output height in pixels (NULL to infer from the SVG).} +} +\value{ +Path to the generated PDF file. +} +\description{ +Convert an SVG string or SVG file path to a \strong{vector} PDF. As with +\code{\link[=svg_to_png]{svg_to_png()}}, the SVG is sanitized and the required WOFF2 fonts are embedded +(downloaded on demand into a user cache) so text renders consistently. +Conversion uses \pkg{rsvg} (\code{\link[rsvg:rsvg_pdf]{rsvg::rsvg_pdf()}}), preserving vector text. + +For headless-Chrome rendering (often best for web fonts), see +\code{\link[=svg_to_pdf_chrome]{svg_to_pdf_chrome()}}. +} +\examples{ +if (requireNamespace("rsvg", quietly = TRUE)) { + svg <- svg_card("FAR", list(), list()) + file_name <- tempfile(fileext = ".pdf") + pdf_path <- svg_to_pdf(svg, file_name) +} +} diff --git a/man/svg_to_png.Rd b/man/svg_to_png.Rd index 4d38601..bc2643d 100644 --- a/man/svg_to_png.Rd +++ b/man/svg_to_png.Rd @@ -40,8 +40,11 @@ dimensions. To make DPI matter, this function scales output pixel size by \code{(dpi / 96)} when \code{width} / \code{height} are not explicitly provided. } \examples{ -svg <- svg_card("FAR", list(), list()) -file_name <- tempfile(fileext = ".png") -png_path <- svg_to_png(svg, file_name, dpi = 300) -png_path <- svg_to_png(svg, file_name, dpi = 300, background = "white") +if (requireNamespace("rsvg", quietly = TRUE) || + requireNamespace("magick", quietly = TRUE)) { + svg <- svg_card("FAR", list(), list()) + file_name <- tempfile(fileext = ".png") + png_path <- svg_to_png(svg, file_name, dpi = 300) + png_path <- svg_to_png(svg, file_name, dpi = 300, background = "white") +} } diff --git a/tests/testthat/test-conversion.R b/tests/testthat/test-conversion.R index b93525a..78e8d2b 100644 --- a/tests/testthat/test-conversion.R +++ b/tests/testthat/test-conversion.R @@ -34,3 +34,24 @@ test_that("detect_svg_fonts finds families and drops generics", { expect_true("Montserrat" %in% fams) expect_false("sans-serif" %in% fams) }) + +test_that("svg_to_pdf writes a vector PDF", { + skip_if_not_installed("rsvg") + svg <- '' + out <- tempfile(fileext = ".pdf") + res <- svg_to_pdf(svg, out) + expect_identical(res, out) + expect_true(file.exists(out)) + expect_gt(file.info(out)$size, 0) + # PDF files start with the "%PDF" magic header + hdr <- readBin(out, "raw", n = 4) + expect_identical(rawToChar(hdr), "%PDF") +}) + +test_that("svg_to_pdf infers a temp output path when NULL", { + skip_if_not_installed("rsvg") + svg <- '' + out <- svg_to_pdf(svg) + expect_true(file.exists(out)) + expect_match(out, "\\.pdf$") +}) diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R index dde86ad..207503f 100644 --- a/tests/testthat/test-utils.R +++ b/tests/testthat/test-utils.R @@ -29,6 +29,10 @@ test_that("is_light_color rejects invalid input (issue #5)", { expect_error(is_light_color(c("#fff", "#000"))) }) +test_that("is_light_color rejects an unknown color name via cli (issue #20)", { + expect_error(is_light_color("notacolor"), "valid color") +}) + test_that("escape_xml escapes special characters and tolerates NA/NULL (issue #2)", { expect_equal(escape_xml("a & b < c > d"), "a & b < c > d") expect_equal(escape_xml("\"x\" 'y'"), ""x" 'y'")