Nothing
build = function(spec) {
x = structure(spec, class = 'lt_tbl')
as.character(lt_static(x, method = 'node', css = FALSE, fragment = TRUE))
}
assert("basic table renders correct cells", {
html = build(list(data = list(x = 1:2, y = c("a", "b"))))
(matches(html, ".*<table.*>x</th>.*>1</td>.*>b</td>.*") %==% "")
})
assert("table is wrapped in a div for horizontal scroll", {
html = build(list(data = list(x = 1:2)))
(matches(html, '.*<div class="lt-wrap"><table.*</table></div>.*') %==% "")
})
assert("cells are HTML-escaped by default", {
html = build(list(data = list(x = c("<b>a</b>", "c & d"))))
(matches(html, ".*><b>a</b></td>.*>c & d</td>.*") %==% "")
})
assert("html_cols = TRUE emits all data cells as raw HTML", {
html = build(list(data = list(x = "<b>a</b>"), html_cols = TRUE))
(matches(html, ".*<td[^>]*><b>a</b></td>.*") %==% "")
})
assert("html_cols marks only the listed columns raw", {
html = build(list(
data = list(a = "<i>x</i>", b = "<i>y</i>"),
html_cols = list("a")
))
# column a is raw, column b is escaped
(matches(html, ".*<td[^>]*><i>x</i></td>.*") %==% "")
(matches(html, ".*><i>y</i></td>.*") %==% "")
})
# A text field (title, label, footnote, ...) wrapped in I() on the R side
# arrives as a one-element array and is emitted verbatim; a plain string is
# escaped.
assert("I()-wrapped title is raw HTML; a plain title is escaped", {
html = build(list(data = list(x = 1), header = list(title = I("<b>T</b>"))))
(matches(html, '.*class="lt-title"><b>T</b>.*') %==% "")
html = build(list(data = list(x = 1), header = list(title = "<b>T</b>")))
(matches(html, '.*class="lt-title"><b>T</b>.*') %==% "")
})
assert("I()-wrapped column label is raw HTML", {
html = build(list(
data = list(a = 1, b = 2),
ops = list(list(type = "label", labels = list(a = I("<b>A</b>"), b = "<b>B</b>")))
))
# column a's label is raw, b's is escaped
(matches(html, ".*<th[^>]*><b>A</b></th>.*") %==% "")
(matches(html, ".*<b>B</b></th>.*") %==% "")
})
assert("html_cols makes separator-row group labels raw HTML", {
html = build(list(
data = list(g = c("<b>A</b>", "<b>A</b>", "<b>B</b>"), v = 1:3),
row_group = "g",
html_cols = list("g")
))
(matches(html, '.*class="lt-row-group"><th[^>]*><b>A</b></th>.*') %==% "")
(matches(html, ".*<b>B</b></th>.*") %==% "")
})
assert("I()-wrapped spanner, footnote, and note are raw HTML", {
html = build(list(
data = list(a = 1, b = 2),
spanners = list(list(label = I("<b>S</b>"), columns = list("a", "b"))),
footnotes = list(list(
text = I("<i>fn</i>"),
location = list(type = "column_labels", columns = list("a"))
)),
notes = list(I("<u>note</u>"))
))
(matches(html, '.*class="lt-spanner"><b>S</b>.*') %==% "")
(matches(html, ".*<i>fn</i></td>.*") %==% "")
(matches(html, ".*<u>note</u></td>.*") %==% "")
})
assert("fmt_number formats decimals", {
html = build(list(
data = list(x = c(1.1, 2.346)),
ops = list(list(type = "fmt_number", columns = list("x"), decimals = 2))
))
(matches(html, ".*>1\\.10</td>.*>2\\.35</td>.*") %==% "")
})
assert("sub replaces NA", {
html = build(list(
data = list(x = c(1, NA)),
ops = list(list(type = "sub", missing = "—"))
))
(matches(html, ".*>—</td>.*") %==% "")
})
assert("separator row groups render correctly", {
html = build(list(
data = list(g = c("A", "A", "B"), v = 1:3),
row_group = "g"
))
(matches(html, '.*class="lt-row-group".*>A</th>.*>B</th>.*') %==% "")
})
assert("rowspan row groups render label + filler cells", {
# A group of n > 1 rows puts its label in a non-spanning cell (so it aligns
# with the row's body cells) plus an empty filler spanning the rest. Group
# "A" spans 3 rows: label on row 1, filler with rowspan="2" after. The
# single-row group "B" is just a plain label cell (no filler, no rowspan).
html = build(list(
data = list(g = c("A", "A", "A", "B"), v = 1:4),
row_group = list("g")
))
(matches(html, '.*<th scope="row" class="lt-row-group lt-row-open">A</th>.*') %==% "")
(matches(html, '.*<th class="lt-row-group" rowspan="2"></th>.*') %==% "")
(matches(html, '.*<th scope="row" class="lt-row-group">B</th>.*') %==% "")
# the label cell must not span rows itself
(grepl('rowspan="[0-9]+">A</th>', html) %==% FALSE)
})
assert("two-row group filler omits the rowspan attribute", {
# A 2-row group leaves n-1 = 1 filler row, which needs no rowspan attribute.
html = build(list(
data = list(g = c("A", "A"), v = 1:2),
row_group = list("g")
))
(matches(html, '.*<th class="lt-row-group"></th>.*') %==% "")
(grepl('rowspan', html) %==% FALSE)
})
assert("column widths align past rowspan group columns", {
# A width set on a body column must apply to that column, not shift onto the
# leading rowspan group column. The colgroup gets an empty <col> per group
# column, so the width lands on the correct <col>.
html = build(list(
data = list(g = c("A", "A"), v = 1:2),
row_group = list("g"),
ops = list(list(type = "width", widths = list(v = "9em")))
))
(matches(html, '.*<colgroup><col><col style="width:9em"></colgroup>.*') %==% "")
})
assert("column spanner renders colspan", {
html = build(list(
data = list(a = 1, b = 2, c = 3),
spanners = list(list(label = "AB", columns = list("a", "b")))
))
(matches(html, '.*colspan="2".*>AB</th>.*') %==% "")
})
assert("footnotes render in tfoot", {
html = build(list(
data = list(x = 1),
footnotes = list(list(
text = "A note",
location = list(type = "column_labels", columns = list("x"))
))
))
(matches(html, '.*<tfoot class="lt-footer">.*> A note</td>.*') %==% "")
})
assert("merge with pattern", {
html = build(list(
data = list(a = "x", b = "y"),
ops = list(list(type = "merge", columns = list("a", "b"), pattern = "{1} ({2})", hide = TRUE))
))
(matches(html, '.*>x \\(y\\)</td>.*') %==% "")
})
assert("style with class and test", {
html = build(list(
data = list(x = c(1, 5)),
ops = list(list(type = "style", columns = list("x"), test = "v => v > 3", class = "hi"))
))
# "hi" applied to cell with 5 but not cell with 1
(matches(html, '.*hi">5</td>.*') %==% "")
(matches(html, '.*hi">1</td>.*') %==% html)
})
assert("sort by groups reorders rows", {
html = build(list(
data = list(g = c("B", "A", "B", "A"), v = c(1, 2, 3, 4)),
row_group = list("g")
))
# After sorting by g, A rows (2,4) come before B rows (1,3)
(matches(html, ".*>2</td>.*>1</td>.*") %==% "")
})
assert("auto-format picks decimals from value width", {
# Small values (<1) get up to 4 decimals; the big_mark separator is a
# non-breaking space (U+00A0).
html = build(list(data = list(x = c(0.123456, 0.987))))
(matches(html, ".*>0\\.1235</td>.*>0\\.9870</td>.*") %==% "")
# Large values are rounded to 0 decimals and grouped.
html = build(list(data = list(x = c(1234.5, 9999.9))))
(matches(html, ".*>1 235</td>.*>10 000</td>.*") %==% "")
})
assert("auto_format = FALSE leaves numbers untouched", {
html = build(list(data = list(x = c(1.23456, 2.34567)), auto_format = FALSE))
(matches(html, ".*>1\\.23456</td>.*>2\\.34567</td>.*") %==% "")
})
assert("per-cell style class merges with the column alignment class", {
# Numeric columns get the "al-r" class; a style class on one cell must be
# appended, not replace it.
html = build(list(
data = list(x = c(1, 5)),
ops = list(list(type = "style", columns = list("x"), rows = list(2L), class = "hot"))
))
(matches(html, '.*class="al-r hot">5</td>.*') %==% "")
# The untouched cell keeps just the alignment class.
(matches(html, '.*class="al-r">1</td>.*') %==% "")
})
assert("fmt_number with percent, big_mark, and minus sign", {
html = build(list(
data = list(x = c(0.1234, 12345.678)),
ops = list(list(type = "fmt_number", columns = list("x"),
percent = TRUE, decimals = 1, big_mark = ","))
))
(matches(html, ".*>12\\.3%</td>.*>1,234,567\\.8%</td>.*") %==% "")
# Negative numbers render with a real minus sign (U+2212), not a hyphen.
html = build(list(
data = list(n = -1234567),
ops = list(list(type = "fmt_number", columns = list("n"), big_mark = ","))
))
(matches(html, ".*>−1,234,567</td>.*") %==% "")
})
assert("infinity renders as ∞, with minus sign depending on formatting", {
# Raw (unformatted, non-numeric column) infinities use an ASCII hyphen.
html = build(list(data = list(x = c(Inf, -Inf)), auto_format = FALSE))
(matches(html, ".*>∞</td>.*>-∞</td>.*") %==% "")
# Auto-formatted numeric column: minus becomes the typographic minus (U+2212).
html = build(list(data = list(x = c(1.5, Inf, -Inf))))
(matches(html, ".*>∞</td>.*>−∞</td>.*") %==% "")
# Explicit fmt_number keeps ±∞ and uses the typographic minus.
html = build(list(
data = list(x = c(Inf, -Inf)),
ops = list(list(type = "fmt_number", columns = list("x"), decimals = 2))
))
(matches(html, ".*>∞</td>.*>−∞</td>.*") %==% "")
})
assert("fmt_number prefix and suffix", {
html = build(list(
data = list(price = c(1234.5, 678.9)),
ops = list(list(
type = "fmt_number", columns = list("price"),
decimals = 2, big_mark = ",", prefix = "$"
))
))
(matches(html, '.*>\\$1,234\\.50</td>.*>\\$678\\.90</td>.*') %==% "")
# suffix
html = build(list(
data = list(weight = c(75, 80)),
ops = list(list(
type = "fmt_number", columns = list("weight"), suffix = " kg"
))
))
(matches(html, '.*>75 kg</td>.*>80 kg</td>.*') %==% "")
# prefix + percent
html = build(list(
data = list(rate = c(0.05, 0.12)),
ops = list(list(
type = "fmt_number", columns = list("rate"),
decimals = 1, percent = TRUE, prefix = "+"
))
))
(matches(html, '.*>\\+5\\.0%</td>.*>\\+12\\.0%</td>.*') %==% "")
})
assert("fmt_date formats ISO date strings", {
html = build(list(
data = list(event = c("A", "B"), date = c("2024-01-15", "2024-06-30")),
ops = list(list(type = "fmt_date", columns = list("date"), method = "toISOString"))
))
(matches(html, '.*>2024-01-15T00:00:00\\.000Z</td>.*>2024-06-30T00:00:00\\.000Z</td>.*') %==% "")
# toUTCString
html = build(list(
data = list(date = "2024-01-15"),
ops = list(list(type = "fmt_date", columns = list("date"), method = "toUTCString"))
))
(matches(html, '.*>Mon, 15 Jan 2024 00:00:00 GMT</td>.*') %==% "")
# null values are skipped
html = build(list(
data = list(date = list("2024-01-15", NULL)),
ops = list(list(type = "fmt_date", columns = list("date"), method = "toISOString"))
))
(matches(html, '.*>2024-01-15T00:00:00\\.000Z</td>.*></td>.*') %==% "")
})
assert("footnote on the title renders a marker in the caption", {
html = build(list(
data = list(x = 1),
header = list(title = "T"),
footnotes = list(list(
text = "tn", location = list(type = "title", group = "title")
))
))
(matches(html, '.*class="lt-title".*class="lt-fnref".*> tn</td>.*') %==% "")
})
assert("footnote on row groups matches by starts_with", {
html = build(list(
data = list(g = c("Apple", "Banana"), v = c(1, 2)),
row_group = "g",
footnotes = list(list(
text = "sn",
location = list(type = "row_groups", match = "starts_with", value = "App")
))
))
# Marker attaches to the "Apple" group header, and only one footnote exists.
(matches(html, '.*Apple<sup class="lt-fnref".*> sn</td>.*') %==% "")
(matches(html, ".*Banana<sup.*") %==% html)
})
assert("auto_label replaces separators with spaces in column headers", {
html = build(list(data = list(Sepal.Length = c(5.1), Petal_Width = c(0.2))))
(matches(html, '.*>Sepal Length</th>.*>Petal Width</th>.*') %==% "")
})
assert("auto_label = FALSE preserves raw column names", {
html = build(list(data = list(Sepal.Length = c(5.1), Petal_Width = c(0.2)), auto_label = FALSE))
(matches(html, '.*>Sepal\\.Length</th>.*>Petal_Width</th>.*') %==% "")
})
assert("merge drops a conditional block when its references are empty", {
# "<<...>>" blocks are emitted only if all {n} refs are non-empty.
html = build(list(
data = list(a = "x", b = "", c = ""),
ops = list(list(type = "merge", columns = list("a", "b", "c"),
pattern = "{1}<<({2}/{3})>>"))
))
(matches(html, ".*>x</td>.*") %==% "")
(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)
})
# A wide table must crop to one page too. scrollWidth is the floor of the
# table's fractional natural width; pinning the body to that floored width
# used to leave it a sub-pixel too narrow, wrapping a cell and growing the
# table past the measured height, so the PDF spilled onto a second page
# (seen on macOS, not Linux). lt_measure() now rounds the width up by 1px.
if (has_browser()) assert("lt_export() crops a wide table to one PDF page", {
# Many columns with long labels give the table a fractional natural width.
x = lt(as.data.frame(matrix(
1:32, 4, 8, dimnames = list(NULL, paste0("a_long_column_label_", 1:8))
)))
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_static().
if (can_bake())
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, ".*<table.*>b</th>.*>1</td>.*>x</td>.*") %==% "")
})
# output = NA returns the HTML string instead of writing a file.
if (can_bake())
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, ".*<table.*>1</td>.*>x</td>.*") %==% "")
})
# 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 <table>.
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, ".*<script>.*LT.*</script>.*") %==% "")
})
# tidy = TRUE pretty-prints the baked <table>: multi-line, indented, with each
# structural tag and cell on its own line.
if (can_bake())
assert("lt_export(tidy = TRUE) pretty-prints the baked table", {
html = lt_export(
lt(data.frame(a = 1:2, b = c("x", "y"))), NA, fragment = TRUE,
css = FALSE, tidy = TRUE
)
# the baked HTML comes back split across lines (joined at write time)
lines = unlist(strsplit(html, "\n"))
(length(lines) > 1L)
(any(grepl("^ <table", lines))) # table indented under the wrap
(any(grepl("^ <tr>$", lines))) # rows on their own line
(any(grepl("^ <td[ >].*</td>$", lines))) # each cell a single line
})
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.