diff --git a/DESCRIPTION b/DESCRIPTION index 3b87379..bd77f56 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: lt Type: Package Title: Lightweight Tables via JSON Specs and JavaScript -Version: 0.1.4 +Version: 0.1.5 Authors@R: person('Yihui', 'Xie', email = 'xie@yihui.name', role = c('aut', 'cre', 'cph'), comment = c(ORCID = '0000-0003-0645-5666', URL = 'https://yihui.org')) Description: A lightweight grammar of tables. Build a table by declaring a JSON @@ -20,6 +20,7 @@ Suggests: htmltools, knitr, litedown, + magick, repr, shiny, testit diff --git a/NAMESPACE b/NAMESPACE index 6c0e547..e0e70b5 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -8,11 +8,11 @@ export(lt) export(lt_align) export(lt_css) export(lt_date) +export(lt_export) export(lt_footnote) export(lt_format) export(lt_group) export(lt_header) -export(lt_html) export(lt_indent) export(lt_label) export(lt_merge) diff --git a/NEWS.md b/NEWS.md index f53ca74..0d3e8d5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,8 +1,6 @@ # CHANGES IN lt VERSION 0.2 -- Added `lt_html()` to render an lt table to a static HTML `` via - Node.js or a headless browser. This is useful for output formats that support - raw HTML but cannot run JavaScript (e.g., GFM). +- Added `lt_export()` to save an lt table to a file: `.html` (an HTML table, optionally baked to a static `
` via Node.js or a headless browser so it needs no JavaScript to view), `.pdf` (a vector PDF), or `.png` (a raster image). PDF and PNG are rendered in a headless Chromium browser and cropped tightly to the table by default. # CHANGES IN lt VERSION 0.1 diff --git a/R/render.R b/R/render.R index 4dd1c14..8e8b67f 100644 --- a/R/render.R +++ b/R/render.R @@ -131,7 +131,7 @@ format.lt_tbl = function(x, fragment = TRUE, inline_assets = TRUE, assets = TRUE if ('js' %in% assets) js_block(inline_assets) ) if (!fragment) body = html_doc(body) - paste(body, collapse = '\n') + xfun::raw_string(paste(body, collapse = '\n')) } #' Print an `lt_tbl` (Opens in the Viewer or Browser) @@ -208,35 +208,21 @@ register_s3 = function(pkgs, generics) { } -#' Render an lt table to a static HTML table -#' -#' Execute the JavaScript runtime to produce a rendered `
` element. Two -#' methods are available: `"node"` uses Node.js; `"browser"` uses a headless -#' Chromium browser (via [xfun::browser_dom()]). The default `"auto"` tries -#' Node first (faster), then falls back to the browser. -#' -#' @param x An `lt_tbl` object. -#' @param method One of `"auto"`, `"browser"`, or `"node"`. -#' @param css Whether to include the lt.css runtime stylesheet in the output. -#' User CSS from [lt_css()] is always included. -#' @param fragment If `FALSE` (default), wrap in a full HTML document. If -#' `TRUE`, return only the rendered fragment. -#' @return A character vector of the rendered HTML. -#' @section Global option: -#' When the option `lt.lt_html` is set to a list of arguments (e.g., -#' `options(lt.lt_html = list(fragment = TRUE, css = FALSE))`), the -#' [knit_print][knitr::knit_print] and [record_print][xfun::record_print] -#' methods will call `lt_html()` with these arguments instead of emitting the -#' default JavaScript-based spec. This is useful for output formats that -#' support raw HTML but cannot run JavaScript (e.g., GitHub Flavored -#' Markdown). -#' @export -#' @examples -#' if (interactive()) lt_html(lt(head(mtcars))) +# Build the HTML for lt_export()'s .html output. `method`: +# "raw" -> the JavaScript-spec HTML (the table is built client-side by +# lt.js at view time); no external tool runs. +# "node" -> run lt.js in Node.js to bake a static
. +# "browser" -> run lt.js in a headless Chromium browser (via browser_dom). +# "auto" -> node if available, else browser. +# `css` includes the lt.css runtime stylesheet (user CSS from lt_css() always +# is); `fragment = FALSE` wraps the result in a full HTML document. lt_html = function( - x, method = c('auto', 'node', 'browser'), css = TRUE, fragment = FALSE + x, method = c('auto', 'node', 'browser', 'raw'), css = TRUE, fragment = FALSE ) { method = match.arg(method) + if (method == 'raw') return(format( + x, fragment = fragment, assets = c(if (css) 'css', 'js') + )) if (method == 'auto') method = if (has_node()) 'node' else if (has_browser()) 'browser' if (is.null(method)) stop( 'No rendering method available. Install a Chromium-based browser or Node.js.' @@ -265,6 +251,187 @@ lt_html_node = function(x, css = TRUE) { c(if (css) css_block(TRUE), user_css_block(x$css), rules_block(x$rules), out) } +# Write `html` to a temp file, run it through headless Chromium via +# `fun`, and clean up. Shared by the measure pass (browser_dom) and the +# render pass (browser_print). +with_temp_html = function(html, fun) { + f = tempfile(fileext = '.html') + on.exit(unlink(f), add = TRUE) + xfun::write_utf8(html, f) + fun(f) +} + +# Layout CSS shared by the measure and render passes: zero the page margins +# so the table sits flush at the top-left, add the crop padding, and set the +# body width. Both passes MUST use identical layout so the size measured in +# pass 1 matches what pass 2 renders. `pad` is c(vertical, horizontal) in CSS +# pixels; `width` is the outer body width in pixels (NULL = shrink to the +# table's natural width). box-sizing keeps `padding` inside `width`. +crop_layout = function(pad, width = NULL) sprintf(paste0( + 'html,body{margin:0!important}', + 'body{box-sizing:border-box;padding:%dpx %dpx!important;width:%s}' +), pad[1L], pad[2L], if (is.null(width)) 'max-content' else paste0(width, 'px')) + +# Measure the rendered table's full pixel size (including the crop padding). +# Chromium runs lt.js, so the box is only known after the JS builds the +# table; inject a load handler that stamps body.scrollWidth/scrollHeight onto +# as data attributes, dump the DOM, and parse them back. We measure +# the body's scroll size rather than the table's bounding box because parts +# of the table (caption border, footer spacing) extend beyond the table's +# own border-box; using the table box alone undercounts the height and makes +# the PDF spill onto a second page. Returns integer c(width, height). +lt_measure = function(html, pad, width = NULL, browser = NULL) { + inject = paste0( + '', + '' + ) + html = sub('', paste0(inject, ''), html, fixed = TRUE) + dom = with_temp_html(html, function(f) xfun::browser_dom(f, browser = browser)) + m = regmatches(dom, regexec('data-ltw="([0-9]+)" data-lth="([0-9]+)"', dom))[[1]] + if (length(m) != 3L) stop('Failed to measure the table dimensions.') + as.integer(m[-1L]) +} + +#' Export an lt table to a file +#' +#' Save a table to disk. The output format is chosen from the file extension +#' of `output`: `.html` writes an HTML table, `.pdf` writes a vector PDF, and +#' any other extension writes a PNG. PDF and PNG are produced by rendering the +#' table in a headless Chromium browser (via [xfun::browser_print()]). +#' +#' For `.html` output, `method` controls how the `
` is produced: +#' `"raw"` writes the JavaScript-spec HTML, so the table is built in the +#' browser by the lt.js runtime when the file is viewed; the other methods +#' bake a static `
` up front by running lt.js once (via `"node"` in +#' Node.js or `"browser"` in a headless Chromium browser; `"auto"` picks Node +#' if available, else the browser), so the saved file needs no JavaScript to +#' view. `method`, `css`, and `fragment` apply only to `.html` output. +#' +#' @param x An `lt_tbl` object. +#' @param output Output file path. Its extension selects the format: `.html`, +#' `.pdf`, or (otherwise) PNG. If `NA`, the HTML is returned as a string +#' instead of being written to a file. +#' @param method How to produce the HTML `
`: `"auto"`, `"node"`, +#' `"browser"`, or `"raw"` (see Details). Applies only to `.html` output. +#' @param css Whether to include the lt.css runtime stylesheet in the HTML +#' output. User CSS from [lt_css()] is always included. Applies only to +#' `.html` output. +#' @param fragment If `FALSE` (default), wrap the HTML in a full HTML document; +#' if `TRUE`, return only the table fragment. Applies only to `.html` +#' output. +#' @param crop Whether to crop the PDF/PNG tightly to the table, removing the +#' surrounding page whitespace. This adds a preliminary browser pass to +#' measure the rendered table. Set to `FALSE` for the default full page. +#' Cropping PNG output requires the \pkg{magick} package; without it, PNG +#' falls back to the full page (with a warning). +#' @param width The width of the table in CSS pixels. By default (`NULL`) it +#' shrinks to the table's natural width. A smaller `width` wraps cell +#' content; a larger one pads the table. +#' @param padding Padding in CSS pixels to keep around the table when +#' cropping. A single value (all sides) or a length-two vector +#' `c(vertical, horizontal)`. +#' @param browser Path to the Chromium-based browser; passed to +#' [xfun::browser_print()]. `NULL` (default) auto-detects. +#' @param ... Passed to [xfun::browser_print()] for PDF/PNG output. +#' @return The `output` path, or (when `output` is `NA`) the HTML as a string. +#' @section Global option: +#' When the option `lt.lt_html` is set to a list of arguments (e.g., +#' `options(lt.lt_html = list(css = FALSE))`), the +#' [knit_print][knitr::knit_print] and [record_print][xfun::record_print] +#' methods emit the same static HTML table as `lt_export(x, "*.html")` (using +#' those arguments as `method`/`css`/`fragment`) instead of the default +#' JavaScript-based spec. This is useful for output formats that support raw +#' HTML but cannot run JavaScript (e.g., GitHub Flavored Markdown). +#' @export +#' @examples +#' tbl = lt(head(mtcars)) +#' +#' # HTML with the JavaScript spec (table built by lt.js when viewed) +#' lt_export(tbl, NA, method = 'raw') # character output +#' f1 = tempfile(fileext = '.html') +#' lt_export(tbl, f1, method = 'raw') # file output +#' +#' # Bake a static
(needs Node.js or a headless browser). +#' if (lt:::has_node() || lt:::has_browser()) +#' lt_export(tbl, NA, method = 'auto', fragment = TRUE, css = FALSE) +#' +#' # PDF / PNG are rendered in a headless browser and cropped to the table. +#' f2 = tempfile(fileext = '.pdf') +#' f3 = tempfile(fileext = '.png') +#' if (lt:::has_browser()) { +#' lt_export(tbl, f2) +#' lt_export(tbl, f3, width = 400) +#' } +#' +#' unlink(c(f1, f2, f3)) +lt_export = function( + x, output = 'lt.html', method = c('auto', 'node', 'browser', 'raw'), + css = TRUE, fragment = FALSE, crop = TRUE, width = NULL, padding = 8, + browser = NULL, ... +) { + # HTML output (also the target when `output` is NA, since there's no + # extension to infer a format from): no browser needed at view time. + if (is.na(output) || tolower(xfun::file_ext(output)) == 'html') { + html = lt_html(x, method, css, fragment) + if (is.na(output)) return(html) + xfun::write_utf8(html, output) + return(output) + } + html = format(x, fragment = FALSE) + is_pdf = tolower(xfun::file_ext(output)) == 'pdf' + # PNG cropping needs magick to trim Chromium's screenshot (its --screenshot + # size is the window size, which we can't shrink below Chromium's minimums). + # PDF cropping needs no extra package: an @page rule sets the page box. + if (crop && !is_pdf && !xfun::loadable('magick')) { + warning('Cropping PNG output requires the magick package; ', + 'exporting the full page instead.') + crop = FALSE + } + pad = rep_len(padding, 2L) # c(vertical, horizontal) + # When cropping or when the user fixes a width, measure the rendered size + # (at that width); `w`/`h` are the outer box including padding. + if (crop || !is.null(width)) { + d = lt_measure(html, pad, width, browser) + w = width %||% d[1L]; h = d[2L] + layout = crop_layout(pad, w) + } + if (crop && is_pdf) { + # @page size drives the PDF page box exactly; no image post-processing. + style = sprintf('', w, h, layout) + html = sub('', paste0(style, ''), html, fixed = TRUE) + with_temp_html(html, function(f) + xfun::browser_print(f, output, browser = browser, ...)) + return(output) + } + if (crop) { + # PNG: render onto a window large enough that the table is drawn + # unscaled at the top-left (Chromium clamps the window to a minimum size + # and reserves some height, and scales content that overflows the + # viewport), then crop the screenshot to the exact table box with magick. + html = sub('', paste0(''), html, fixed = TRUE) + png = tempfile(fileext = '.png') + on.exit(unlink(png), add = TRUE) + with_temp_html(html, function(f) xfun::browser_print( + f, png, browser = browser, window_size = c(max(500L, w), h + 120L) + )) + img = magick::image_crop(magick::image_read(png), sprintf('%dx%d+0+0', w, h)) + magick::image_write(img, output, format = 'png') + return(output) + } + # No crop: honor an explicit width via body layout + window_size, else fall + # back to browser_print's default full page. + args = list(browser = browser, ...) + if (!is.null(width)) { + html = sub('', paste0(''), html, fixed = TRUE) + args$window_size = c(w, h) + } + with_temp_html(html, function(f) + do.call(xfun::browser_print, c(list(f, output), args))) + output +} + has_browser = function() { tryCatch({xfun:::check_browser(NULL); TRUE}, error = function(e) FALSE) } diff --git a/man/lt_export.Rd b/man/lt_export.Rd new file mode 100644 index 0000000..75a5270 --- /dev/null +++ b/man/lt_export.Rd @@ -0,0 +1,107 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/render.R +\name{lt_export} +\alias{lt_export} +\title{Export an lt table to a file} +\usage{ +lt_export( + x, + output = "lt.html", + method = c("auto", "node", "browser", "raw"), + css = TRUE, + fragment = FALSE, + crop = TRUE, + width = NULL, + padding = 8, + browser = NULL, + ... +) +} +\arguments{ +\item{x}{An \code{lt_tbl} object.} + +\item{output}{Output file path. Its extension selects the format: \code{.html}, +\code{.pdf}, or (otherwise) PNG. If \code{NA}, the HTML is returned as a string +instead of being written to a file.} + +\item{method}{How to produce the HTML \verb{
}: \code{"auto"}, \code{"node"}, +\code{"browser"}, or \code{"raw"} (see Details). Applies only to \code{.html} output.} + +\item{css}{Whether to include the lt.css runtime stylesheet in the HTML +output. User CSS from \code{\link[=lt_css]{lt_css()}} is always included. Applies only to +\code{.html} output.} + +\item{fragment}{If \code{FALSE} (default), wrap the HTML in a full HTML document; +if \code{TRUE}, return only the table fragment. Applies only to \code{.html} +output.} + +\item{crop}{Whether to crop the PDF/PNG tightly to the table, removing the +surrounding page whitespace. This adds a preliminary browser pass to +measure the rendered table. Set to \code{FALSE} for the default full page. +Cropping PNG output requires the \pkg{magick} package; without it, PNG +falls back to the full page (with a warning).} + +\item{width}{The width of the table in CSS pixels. By default (\code{NULL}) it +shrinks to the table's natural width. A smaller \code{width} wraps cell +content; a larger one pads the table.} + +\item{padding}{Padding in CSS pixels to keep around the table when +cropping. A single value (all sides) or a length-two vector +\code{c(vertical, horizontal)}.} + +\item{browser}{Path to the Chromium-based browser; passed to +\code{\link[xfun:browser_print]{xfun::browser_print()}}. \code{NULL} (default) auto-detects.} + +\item{...}{Passed to \code{\link[xfun:browser_print]{xfun::browser_print()}} for PDF/PNG output.} +} +\value{ +The \code{output} path, or (when \code{output} is \code{NA}) the HTML as a string. +} +\description{ +Save a table to disk. The output format is chosen from the file extension +of \code{output}: \code{.html} writes an HTML table, \code{.pdf} writes a vector PDF, and +any other extension writes a PNG. PDF and PNG are produced by rendering the +table in a headless Chromium browser (via \code{\link[xfun:browser_print]{xfun::browser_print()}}). +} +\details{ +For \code{.html} output, \code{method} controls how the \verb{
} is produced: +\code{"raw"} writes the JavaScript-spec HTML, so the table is built in the +browser by the lt.js runtime when the file is viewed; the other methods +bake a static \verb{
} up front by running lt.js once (via \code{"node"} in +Node.js or \code{"browser"} in a headless Chromium browser; \code{"auto"} picks Node +if available, else the browser), so the saved file needs no JavaScript to +view. \code{method}, \code{css}, and \code{fragment} apply only to \code{.html} output. +} +\section{Global option}{ + +When the option \code{lt.lt_html} is set to a list of arguments (e.g., +\code{options(lt.lt_html = list(css = FALSE))}), the +\link[knitr:knit_print]{knit_print} and \link[xfun:record_print]{record_print} +methods emit the same static HTML table as \code{lt_export(x, "*.html")} (using +those arguments as \code{method}/\code{css}/\code{fragment}) instead of the default +JavaScript-based spec. This is useful for output formats that support raw +HTML but cannot run JavaScript (e.g., GitHub Flavored Markdown). +} + +\examples{ +tbl = lt(head(mtcars)) + +# HTML with the JavaScript spec (table built by lt.js when viewed) +lt_export(tbl, NA, method = 'raw') # character output +f1 = tempfile(fileext = '.html') +lt_export(tbl, f1, method = 'raw') # file output + +# Bake a static
(needs Node.js or a headless browser). +if (lt:::has_node() || lt:::has_browser()) + lt_export(tbl, NA, method = 'auto', fragment = TRUE, css = FALSE) + +# PDF / PNG are rendered in a headless browser and cropped to the table. +f2 = tempfile(fileext = '.pdf') +f3 = tempfile(fileext = '.png') +if (lt:::has_browser()) { + lt_export(tbl, f2) + lt_export(tbl, f3, width = 400) +} + +unlink(c(f1, f2, f3)) +} diff --git a/man/lt_html.Rd b/man/lt_html.Rd deleted file mode 100644 index 8256b09..0000000 --- a/man/lt_html.Rd +++ /dev/null @@ -1,42 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/render.R -\name{lt_html} -\alias{lt_html} -\title{Render an lt table to a static HTML table} -\usage{ -lt_html(x, method = c("auto", "node", "browser"), css = TRUE, fragment = FALSE) -} -\arguments{ -\item{x}{An \code{lt_tbl} object.} - -\item{method}{One of \code{"auto"}, \code{"browser"}, or \code{"node"}.} - -\item{css}{Whether to include the lt.css runtime stylesheet in the output. -User CSS from \code{\link[=lt_css]{lt_css()}} is always included.} - -\item{fragment}{If \code{FALSE} (default), wrap in a full HTML document. If -\code{TRUE}, return only the rendered fragment.} -} -\value{ -A character vector of the rendered HTML. -} -\description{ -Execute the JavaScript runtime to produce a rendered \verb{
} element. Two -methods are available: \code{"node"} uses Node.js; \code{"browser"} uses a headless -Chromium browser (via \code{\link[xfun:browser_dom]{xfun::browser_dom()}}). The default \code{"auto"} tries -Node first (faster), then falls back to the browser. -} -\section{Global option}{ - -When the option \code{lt.lt_html} is set to a list of arguments (e.g., -\code{options(lt.lt_html = list(fragment = TRUE, css = FALSE))}), the -\link[knitr:knit_print]{knit_print} and \link[xfun:record_print]{record_print} -methods will call \code{lt_html()} with these arguments instead of emitting the -default JavaScript-based spec. This is useful for output formats that -support raw HTML but cannot run JavaScript (e.g., GitHub Flavored -Markdown). -} - -\examples{ -if (interactive()) lt_html(lt(head(mtcars))) -} diff --git a/tests/test-ci/test-js.R b/tests/test-ci/test-js.R index f1a6d4b..f11911f 100644 --- a/tests/test-ci/test-js.R +++ b/tests/test-ci/test-js.R @@ -241,3 +241,92 @@ assert("merge drops a conditional block when its references are empty", { (matches(html, ".*>x.*") %==% "") (matches(html, ".*\\(/\\).*") %==% html) }) + +# Count the pages in a PDF file (each page object is "/Type /Page" not +# followed by "s", which would be "/Pages"). +pdf_pages = function(f) { + raw = readChar(f, file.info(f)$size, useBytes = TRUE) + length(gregexpr("/Type\\s*/Page[^s]", raw)[[1]]) +} + +if (has_browser()) assert("lt_export() writes PDF and PNG by extension", { + x = lt(data.frame(a = 1:2, b = c("x", "y"))) + pdf = tempfile(fileext = ".pdf") + png = tempfile(fileext = ".png") + on.exit(unlink(c(pdf, png)), add = TRUE) + + out = lt_export(x, pdf) + (out %==% pdf) + (file.exists(pdf)) + # PDF magic bytes: "%PDF" + (rawToChar(readBin(pdf, "raw", 4L)) %==% "%PDF") + # A cropped table fits on a single page. + (pdf_pages(pdf) %==% 1L) + + lt_export(x, png) + (file.exists(png)) + # PNG magic bytes: 0x89 "PNG" + (readBin(png, "raw", 4L) %==% as.raw(c(0x89, 0x50, 0x4e, 0x47))) +}) + +# A taller/wider table (more rows than the PDF measurement used to account +# for) must still crop to one page: the table's caption border and footer +# spacing extend past the table's own border-box, so we measure the body's +# scroll size, not the table box. +if (has_browser()) assert("lt_export() crops a large table to one PDF page", { + x = lt(head(iris, 20)) + pdf = tempfile(fileext = ".pdf") + on.exit(unlink(pdf), add = TRUE) + lt_export(x, pdf) + (pdf_pages(pdf) %==% 1L) +}) + +if (has_browser() && xfun::loadable("magick")) + assert("lt_export() crops PNG tightly to the table size", { + x = lt(head(mtcars)) + d = lt_measure(format(x, fragment = FALSE), c(8L, 8L), NULL) + png = tempfile(fileext = ".png") + on.exit(unlink(png), add = TRUE) + lt_export(x, png, padding = 8) + info = magick::image_info(magick::image_read(png)) + # The cropped image matches the measured content box exactly. + (info$width %==% d[1L]) + (info$height %==% d[2L]) + }) + +# An explicit width overrides the measured width for both PDF and PNG, +# regardless of crop. +if (has_browser() && xfun::loadable("magick")) + assert("lt_export() honors an explicit width", { + x = lt(head(mtcars)) + png = tempfile(fileext = ".png") + on.exit(unlink(png), add = TRUE) + lt_export(x, png, width = 400) + (magick::image_info(magick::image_read(png))$width %==% 400L) + }) + +# .html output bakes a static table via lt_html(). +if (has_node() || has_browser()) + assert("lt_export() writes a static HTML file", { + html = tempfile(fileext = ".html") + on.exit(unlink(html), add = TRUE) + out = lt_export(lt(data.frame(a = 1:2, b = c("x", "y"))), html) + (out %==% html) + txt = readLines(html) + (matches(txt, ".*b.*>1.*>x.*") %==% "") + }) + +# output = NA returns the HTML string instead of writing a file. +if (has_node() || has_browser()) + assert("lt_export(output = NA) returns the HTML string", { + html = lt_export(lt(data.frame(a = 1:2, b = c("x", "y"))), NA) + (is.character(html)) + (matches(html, ".*1.*>x.*") %==% "") + }) + +# method = "raw" writes the JavaScript-spec HTML (table built client-side), +# so the file carries the lt.js runtime and the spec, not a baked
. +assert('lt_export(method = "raw") emits the JS spec, no external tool', { + html = lt_export(lt(data.frame(a = 1:2)), NA, method = "raw") + (matches(html, ".*.*") %==% "") +}) diff --git a/tests/testit/test-render.R b/tests/testit/test-render.R index 625d082..dd3cffa 100644 --- a/tests/testit/test-render.R +++ b/tests/testit/test-render.R @@ -13,7 +13,7 @@ assert("format(fragment = FALSE) wraps in DOCTYPE", { }) assert("format(assets = FALSE) omits runtime", { - html = format(x, assets = FALSE) + html = unclass(format(x, assets = FALSE)) (matches(html, ".*