lt_tbl to HTMLlt_tbl (Opens in the Viewer or Browser)
-- F --
format.lt_tbl()
-- L --
lt-package
lt()
lt.default()
lt_align()
lt_css()
lt_footnote()
lt_format()
lt_group()
lt_header()
lt_indent()
lt_label()
lt_merge()
lt_move()
lt_note()
lt_output()
lt_spanner()
lt_style()
lt_sub()
lt_width()
-- P --
print.lt_tbl()
-- R --
render_lt()
A small grammar of tables. A table is a data frame plus a list of operations (title, spanner, footnote, ...); the operations are serialised to a JSON spec and applied to a plain semantic HTML table by a tiny vanilla JavaScript runtime at render time.
Maintainer: Yihui Xie [email protected] (ORCID) [copyright holder]
Authors:
Yihui Xie [email protected] (ORCID) [copyright holder]
Useful links:
Report bugs at https://github.com/yihui/lt/issues
lt_tbl to HTMLEmits the CSS+JS runtime and a script block carrying the table's JSON spec. Multiple tables on the same page only need the runtime once.
## S3 method for class 'lt_tbl'
format(x, fragment = TRUE, inline_assets = TRUE, assets = TRUE, ...)
x |
An |
fragment |
If |
inline_assets |
If |
assets |
If |
... |
Reserved for future use. |
A character scalar containing HTML.
tbl = lt(head(mtcars))
html = format(tbl)
format(tbl, fragment = FALSE)
#> [1] "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>lt</title>\n<style>body{font-family:system-ui,sans-serif;padding:1em}</style></head>\n<body>\n<style>\nth, td{ padding: 5px; }\n.lt-table {\n --bg: 4px solid transparent;\n\n border-collapse: separate;\n caption-side: top;\n margin: 1em auto;\n border-spacing: 0;\n border-top: 1.5px solid #888;\n border-bottom: 1.5px solid #888;\n\n caption {\n padding: .5em;\n text-align: center;\n .lt-title { font-size: 1.2em; }\n .lt-subtitle { font-size: 0.9em; }\n }\n\n th { font-weight: normal; }\n tbody {\n :not(:last-child) :is(td, th) { border-bottom: 1px solid #ddd; }\n .lt-row-group :is(td, th) { border-bottom: 1.5px solid #ddd; }\n .lt-row-group { text-align: left; }\n .lt-row-group { vertical-align: top; }\n }\n thead th {\n border-bottom: 1px solid #888;\n &:not(:first-child) { border-left: var(--bg); }\n &:not(:last-child) { border-right: var(--bg); }\n }\n .lt-indent { padding-left: 1em; }\n .lt-spanner-row {\n .lt-spanner { border-bottom: 1px solid #bbb; text-align: center; }\n .lt-spanner-empty { border-bottom: none; }\n }\n\n .lt-footer {\n font-size: 0.85em;\n .lt-footnote:first-child td { border-top: 1px solid #aaa; }\n .lt-source-note:first-child td { border-top: 1px dashed #aaa; }\n .lt-source-note td { font-style: italic; }\n }\n\n .al-r { text-align: right; white-space: nowrap; }\n .al-c { text-align: center; }\n\n .lt-fnref {\n color: #06c;\n &::before { content: \" [\"; }\n &::after { content: \"]\"; }\n }\n}\n</style>\n<script>((window.LT=window.LT||{}).q=window.LT.q||[]).push({s:document.currentScript,d:\n{\n \"data\": {\n \"mpg\": [21, 21, 22.8, 21.4, 18.7, 18.1],\n \"cyl\": [6, 6, 4, 6, 8, 6],\n \"disp\": [160, 160, 108, 258, 360, 225],\n \"hp\": [110, 110, 93, 110, 175, 105],\n \"drat\": [3.9, 3.9, 3.85, 3.08, 3.15, 2.76],\n \"wt\": [2.62, 2.875, 2.32, 3.215, 3.44, 3.46],\n \"qsec\": [16.46, 17.02, 18.61, 19.44, 17.02, 20.22],\n \"vs\": [0, 0, 1, 1, 0, 1],\n \"am\": [1, 1, 1, 0, 0, 0],\n \"gear\": [4, 4, 4, 3, 3, 3],\n \"carb\": [4, 4, 1, 1, 2, 1]\n }\n}\n})</script>\n<script>\n/* lt.js — build a semantic <table> from a JSON spec.\n * Call LT.build(spec) from an inline <script> to render a table in place.\n * One runtime per page renders any number of tables.\n */\n(root => {\n \"use strict\";\n if (root.LT?.buildHtml) return; // duplicate inclusion is a no-op\n\n // `[<]` (not `<`) avoids `</…` so this file is safe to inline in <script>.\n const esc = s => String(s)\n .replace(/&/g, \"&\").replace(/[<]/g, \"<\")\n .replace(/>/g, \">\").replace(/\"/g, \""\");\n const sup = i => `<sup class=\"lt-fnref\">${i}</sup>`;\n // Stringify, mapping null/undefined to \"\" (0 and false stringify normally).\n const str = v => String(v ?? \"\");\n\n // --- Number formatting ---\n const isNum = v => typeof v === \"number\" && !isNaN(v);\n // A column is \"numeric\" if its first non-null value is a number.\n const numCol = col => col?.length && typeof col.find(v => v != null) === \"number\";\n\n function fmtNumber(v, decimals, bigMark) {\n if (!isNum(v)) return null;\n let s = decimals != null ? v.toFixed(decimals) : String(v);\n if (/^-0(\\.0+)?$/.test(s)) s = s.slice(1);\n if (bigMark) {\n const parts = s.split(\".\");\n parts[0] = parts[0].replace(/\\B(?=(\\d{3})+(?!\\d))/g, bigMark);\n s = parts.join(\".\");\n }\n if (s[0] === \"-\") s = \"−\" + s.slice(1);\n return decimals != null || bigMark ? s : null;\n }\n\n // --- Pattern merge ---\n function mergeVals(vals, pattern) {\n if (!pattern) return vals.filter(v => v !== \"\").join(\" \");\n let remaining = pattern, result = \"\";\n while (remaining) {\n const m = remaining.match(/<<(.*?)>>/);\n if (!m) { result += subRefs(remaining, vals); break; }\n if (m.index > 0) result += subRefs(remaining.slice(0, m.index), vals);\n const block = m[1],\n refs = [...block.matchAll(/\\{(\\d+)\\}/g)].map(x => +x[1]);\n if (!refs.length || refs.every(i => vals[i - 1] !== \"\"))\n result += subRefs(block, vals);\n remaining = remaining.slice(m.index + m[0].length);\n }\n return result;\n }\n function subRefs(s, vals) {\n for (let i = 0; i < vals.length; i++)\n s = s.split(\"{\" + (i + 1) + \"}\").join(vals[i]);\n return s;\n }\n\n // --- Auto-format numeric columns ---\n function autoFmt(spec, display, nRow) {\n if (spec.auto_fmt === false) return;\n const data = spec.data || {}, ops = spec.ops || [],\n colNames = Object.keys(data);\n\n const fmtCols = new Set();\n for (const op of ops) {\n if (op.type === \"fmt_number\") for (const c of (op.columns || colNames)) fmtCols.add(c);\n }\n\n const getLabel = c => {\n for (const op of ops) {\n if (op.type === \"label\" && op.labels?.[c] != null) return op.labels[c];\n }\n return c;\n };\n\n for (const c of colNames) {\n if (fmtCols.has(c)) continue;\n const col = data[c];\n if (!numCol(col)) continue;\n\n const lbl = getLabel(c),\n pct = /%|[ _](pct|percent)$/i.test(lbl);\n\n if (/year/i.test(lbl) && col.every(v => v == null || /^\\d{4}$/.test(String(v)))) continue;\n\n // Determine decimal places: find max significant decimals across column,\n // then cap dynamically based on the largest integer-part width (targeting\n // ~4 total significant digits): e.g., values <1 get up to 4 decimals,\n // 10-99 get 2, >=1000 get 0.\n let maxInt = 0, n = 0;\n for (const v of col) {\n if (!isNum(v)) continue;\n // Number of digits before the decimal point\n const a = Math.abs(pct ? v * 100 : v),\n intW = a < 1 ? 0 : Math.floor(Math.log10(a)) + 1;\n if (intW > maxInt) maxInt = intW;\n // Significant decimals (strip trailing zeros; adjust for pct *100)\n const m = String(v).match(/\\.(\\d+)/);\n if (!m) continue;\n const d = m[1].replace(/0+$/, \"\").length - (pct ? 2 : 0);\n if (d > n) n = d;\n }\n // Cap: 4 decimals max, minus integer width (so large numbers get fewer).\n // n is already >= 0, so only the upper cap is needed.\n n = Math.min(n, Math.max(4 - maxInt, 0));\n\n for (let i = 0; i < nRow; i++) {\n const v = col[i];\n if (!isNum(v)) continue;\n const s = fmtNumber(pct ? v * 100 : v, n, \" \");\n if (s != null) display[c][i] = s + (pct ? \"%\" : \"\");\n }\n }\n }\n\n // --- Apply ops to data columns, return display strings per column ---\n function applyOps(spec) {\n const data = spec.data || {},\n ops = spec.ops || [],\n colNames = Object.keys(data),\n nRow = colNames.length ? data[colNames[0]].length : 0;\n\n // Working copy: display[col][row] = string\n const display = {};\n for (const c of colNames) {\n display[c] = data[c].map(str);\n }\n\n autoFmt(spec, display, nRow);\n\n // Run fn(col, row, rawValue) over each existing cell of the given columns.\n const eachCell = (cols, fn) => {\n for (const c of cols) {\n if (!data[c]) continue;\n for (let i = 0; i < nRow; i++) fn(c, i, data[c][i]);\n }\n };\n\n for (const op of ops) {\n const cols = op.columns || colNames;\n switch (op.type) {\n case \"fmt_number\":\n eachCell(cols, (c, i, raw) => {\n if (!isNum(raw)) return;\n const v = op.percent === true ? raw * 100 : raw,\n sfx = op.percent ? \"%\" : \"\",\n f = fmtNumber(v, op.decimals, op.big_mark ?? \"\");\n if (f != null) display[c][i] = f + sfx;\n else if (sfx) display[c][i] = String(v) + sfx;\n });\n break;\n case \"sub\":\n eachCell(cols, (c, i, raw) => {\n if (op.small != null && isNum(raw) && raw !== 0 &&\n Math.abs(raw) < op.small) {\n display[c][i] = op.small_text ?? (\"<\" + op.small);\n } else if (op.zero != null && raw === 0) {\n display[c][i] = op.zero;\n } else if (op.missing != null && raw == null) {\n display[c][i] = op.missing;\n }\n });\n break;\n case \"merge\": {\n const mCols = op.columns;\n if (!mCols || mCols.length < 2) break;\n const target = mCols[0];\n for (let i = 0; i < nRow; i++) {\n const vals = mCols.map(c => display[c]?.[i] ?? \"\");\n display[target][i] = mergeVals(vals, op.pattern);\n }\n break;\n }\n }\n }\n return { display, nRow };\n }\n\n // --- Resolve structural spec fields from ops + data ---\n function resolveSpec(spec) {\n const data = spec.data || {},\n ops = spec.ops || [],\n colNames = Object.keys(data),\n nRow = colNames.length ? data[colNames[0]].length : 0,\n // Run fn on each op of the given type, in document order.\n onOp = (t, fn) => { for (const op of ops) if (op.type === t) fn(op); };\n\n // row_group: array → rowspan mode; string → separator-row mode\n let rowGroupSep = typeof spec.row_group === \"string\";\n const rowGroupCols = Array.isArray(spec.row_group) ? spec.row_group\n : (spec.row_group ? [spec.row_group] : []);\n if (!rowGroupSep && rowGroupCols.length === 1 &&\n data[rowGroupCols[0]]?.some(v => (v + \"\").length > 20)) rowGroupSep = true;\n\n // Hidden columns: row_group, merge sources\n const hidden = new Set();\n for (const g of rowGroupCols) hidden.add(g);\n onOp(\"merge\", op => {\n if (op.hide !== false && op.columns)\n op.columns.slice(1).forEach(c => hidden.add(c));\n });\n\n // Visible columns after hiding\n let visible = colNames.filter(c => !hidden.has(c));\n\n // Apply move\n onOp(\"move\", op => {\n const toMove = (op.columns || []).filter(c => visible.includes(c));\n if (!toMove.length) return;\n const rest = visible.filter(c => !toMove.includes(c));\n if (op.after == null) {\n visible = [...toMove, ...rest];\n } else {\n const pos = rest.indexOf(op.after);\n if (pos >= 0) visible = [...rest.slice(0, pos + 1), ...toMove, ...rest.slice(pos + 1)];\n }\n });\n\n // Set arr[i] = val where i is the position of column c in `visible`.\n const setByCol = (arr, c, val) => { const i = visible.indexOf(c); if (i >= 0) arr[i] = val; };\n\n // Alignment: default from data type (number→right, else left), then overrides\n const align = visible.map(c => numCol(data[c]) ? \"right\" : \"left\");\n onOp(\"align\", op => { for (const c of (op.columns || [])) setByCol(align, c, op.align); });\n\n // Column labels\n const colLabels = [...visible];\n onOp(\"label\", op => {\n for (const [c, lbl] of Object.entries(op.labels || {})) setByCol(colLabels, c, lbl);\n });\n\n // Column widths\n let colWidths = null;\n onOp(\"width\", op => {\n if (!op.widths) return;\n if (!colWidths) colWidths = visible.map(() => \"\");\n for (const [c, w] of Object.entries(op.widths)) setByCol(colWidths, c, w);\n });\n\n // Indent\n const indent = new Array(nRow).fill(0);\n onOp(\"indent\", op => {\n if (op.rows) for (const r of op.rows) indent[r - 1] = op.level ?? 1;\n });\n\n // Runs of equal consecutive values in col → [{ label, rows (1-based) }].\n // Used for both separator-row groups and rowspan span sizes.\n const runs = col => {\n const out = [];\n for (let i = 0; i < nRow;) {\n const label = str(col[i]), rows = [i + 1];\n while (++i < nRow && str(col[i]) === label) rows.push(i + 1);\n out.push({ label, rows });\n }\n return out;\n };\n\n // Row groups from data column (separator-row mode only)\n let groups = [];\n if (rowGroupSep && rowGroupCols.length && data[rowGroupCols[0]])\n groups.push(...runs(data[rowGroupCols[0]]));\n // Manual groups\n onOp(\"row_group\", op => groups.push({ label: op.label, rows: op.rows }));\n // Group ordering\n onOp(\"group_order\", op => {\n if (!groups.length) return;\n const ordered = [], rest = [];\n for (const lbl of (op.order || [])) {\n const g = groups.find(x => x.label === lbl);\n if (g) ordered.push(g);\n }\n for (const g of groups) if (!ordered.includes(g)) rest.push(g);\n groups = [...ordered, ...rest];\n });\n\n // Rowspan groups: compute span sizes for each group column\n const rowSpans = [];\n if (!rowGroupSep && rowGroupCols.length) {\n for (const gc of rowGroupCols) {\n const spans = new Array(nRow).fill(0);\n for (const r of runs(data[gc] || [])) spans[r.rows[0] - 1] = r.rows.length;\n // Resolve display label from label ops (last wins)\n let hdr = gc;\n onOp(\"label\", op => { if (op.labels?.[gc] != null) hdr = op.labels[gc]; });\n rowSpans.push({ col: gc, label: hdr, spans });\n }\n }\n\n // Styles: the style ops are consumed directly (css/class/columns/rows/test).\n const styles = ops.filter(op => op.type === \"style\");\n\n // Auto-spanners: split column names on separator, group contiguous prefixes\n let spanners = spec.spanners || [];\n if (spec.auto_span) {\n const sep = typeof spec.auto_span === \"string\" ? new RegExp(spec.auto_span) : /[._]/;\n spanners = [...spanners];\n for (let i = 0; i < visible.length;) {\n const m = visible[i].match(sep);\n if (!m) { i++; continue; }\n const prefix = visible[i].slice(0, m.index);\n let j = i + 1;\n while (j < visible.length) {\n const m2 = visible[j].match(sep);\n if (!m2 || visible[j].slice(0, m2.index) !== prefix) break;\n j++;\n }\n if (j - i >= 2) {\n spanners.push({ label: prefix, columns: visible.slice(i, j) });\n for (let k = i; k < j; k++) {\n const mk = colLabels[k].match(sep);\n if (mk) colLabels[k] = colLabels[k].slice(mk.index + mk[0].length);\n }\n }\n i = j;\n }\n }\n\n return {\n visible, align, colLabels, colWidths, indent,\n groups, rowSpans, styles, spanners,\n footnotes: spec.footnotes || [],\n notes: spec.notes || [],\n header: spec.header || {},\n nRow\n };\n }\n\n // Footnotes: dedup by text, assign 1..N in first-seen order.\n function indexFootnotes(fns) {\n const order = [], idx = {};\n fns.forEach(f => {\n if (idx[f.text] == null) { idx[f.text] = order.length + 1; order.push(f.text); }\n });\n return { order, idx };\n }\n\n // Find the footnote index for a (type, value) location; 0 if none.\n // `val` is a scalar: the title group, a single column, a spanner label,\n // or a row-group label, depending on `type`.\n const matcher = (fns, idx) => (type, val) => {\n for (const f of fns) {\n const loc = f.location;\n if (loc.type !== type) continue;\n const hit = idx[f.text];\n if (type === \"title\" && loc.group === val) return hit;\n if (type === \"column_labels\" && loc.columns.includes(val)) return hit;\n if (type === \"column_spanners\" && loc.spanners.includes(val)) return hit;\n if (type === \"row_groups\") {\n if (loc.match === \"all\") return hit;\n if (loc.match === \"exact\" && loc.values.includes(val)) return hit;\n if (loc.match === \"starts_with\" && val.startsWith(loc.value)) return hit;\n }\n }\n return 0;\n };\n\n function sortByGroups(spec) {\n if (spec.sort === false || !spec.row_group) return;\n const data = spec.data || {},\n cols = [].concat(spec.row_group),\n colNames = Object.keys(data),\n nRow = colNames.length ? data[colNames[0]].length : 0;\n if (!nRow) return;\n const idx = Array.from({length: nRow}, (_, i) => i);\n idx.sort((a, b) => {\n for (const c of cols) {\n const va = data[c]?.[a], vb = data[c]?.[b];\n if (va == vb) continue;\n if (va == null) return 1;\n if (vb == null) return -1;\n if (va < vb) return -1;\n if (va > vb) return 1;\n }\n return 0;\n });\n for (const c of colNames) data[c] = idx.map(i => data[c][i]);\n // Remap 1-based row indices in ops to reflect the new order\n const newPos = new Array(nRow);\n for (let i = 0; i < nRow; i++) newPos[idx[i]] = i + 1;\n for (const op of (spec.ops || [])) {\n if (op.rows) op.rows = op.rows.map(r => newPos[r - 1]);\n }\n }\n\n function buildHtml(spec) {\n sortByGroups(spec);\n const data = spec.data || {},\n { display, nRow } = applyOps(spec),\n { visible: cols, align, colLabels, colWidths, indent,\n groups, rowSpans, styles, spanners, footnotes: fns, notes, header: hdr } = resolveSpec(spec),\n reg = indexFootnotes(fns),\n fIdx = matcher(fns, reg.idx),\n nGrp = rowSpans.length,\n nCol = cols.length + nGrp,\n out = [`<table class=\"lt-table\">`];\n const mark = (type, val) => { const i = fIdx(type, val); return i ? sup(i) : \"\"; };\n const cell = (c, r) => display[c]?.[r - 1] ?? \"\";\n // ` name=\"val\"` for a truthy val, else \"\" — for optional HTML attributes.\n const attr = (n, v) => v ? ` ${n}=\"${v}\"` : \"\";\n // Plain class names per column (alignment + leading-indent), \"\" if none.\n const colCls = cols.map((_, i) => (\n (i === 0 && groups.length ? \"lt-indent \" : \"\") +\n ({right: \"al-r\", center: \"al-c\"}[align[i]] || \"\")\n ).trimEnd());\n\n // Visit each cell at (rows × cols) that exists in the table, calling\n // fn(key, c, r). rows null = all rows; cols falls back to all columns.\n const eachLoc = (rows, locCols, fn) => {\n for (let r = 1; r <= nRow; r++) {\n if (rows && !rows.includes(r)) continue;\n for (const c of (locCols || cols)) {\n const ci = cols.indexOf(c);\n if (ci >= 0) fn(`${r},${ci}`, c, r);\n }\n }\n };\n\n // Style map: accumulate css (\";\") and class (\" \") per cell.\n const styleMap = {}, classMap = {};\n const addTo = (map, key, val, sep) => { map[key] = map[key] ? map[key] + sep + val : val; };\n for (const s of styles)\n eachLoc(s.rows, s.columns ? [].concat(s.columns) : null, (key, c, r) => {\n if (s.test && !s.test(data[c][r - 1])) return;\n if (s.css) addTo(styleMap, key, s.css, \";\");\n if (s.class) addTo(classMap, key, s.class, \" \");\n });\n\n // <colgroup>\n if (colWidths && colWidths.some(w => w)) {\n out.push(`<colgroup>`);\n for (let i = 0; i < cols.length; i++)\n out.push(`<col${attr(\"style\", colWidths[i] ? \"width:\" + colWidths[i] : \"\")}>`);\n out.push(`</colgroup>`);\n }\n\n // <caption>: title + subtitle, each a <div> rendered only when non-empty.\n const caption = [[\"title\", \"lt-title\"], [\"subtitle\", \"lt-subtitle\"]]\n .filter(([g]) => hdr[g] != null && hdr[g] !== \"\")\n .map(([g, cls]) => `<div class=\"${cls}\">${esc(hdr[g])}${mark(\"title\", g)}</div>`);\n if (caption.length)\n out.push(`<caption class=\"lt-caption\">`, ...caption, `</caption>`);\n\n // <thead>\n out.push(`<thead>`);\n if (spanners.length) {\n const emptyTh = `<th class=\"lt-spanner-empty\"></th>`;\n out.push(`<tr class=\"lt-spanner-row\">`);\n out.push(emptyTh.repeat(nGrp));\n for (let k = 0; k < cols.length;) {\n const sp = spanners.find(s => s.columns[0] === cols[k]);\n if (sp) {\n out.push(`<th colspan=\"${sp.columns.length}\" scope=\"colgroup\" class=\"lt-spanner\">${esc(sp.label)}${mark(\"column_spanners\", sp.label)}</th>`);\n k += sp.columns.length;\n } else {\n out.push(emptyTh);\n k++;\n }\n }\n out.push(`</tr>`);\n }\n out.push(`<tr>`);\n for (const rs of rowSpans) out.push(`<th scope=\"col\">${esc(rs.label)}</th>`);\n for (let i = 0; i < cols.length; i++)\n out.push(`<th scope=\"col\"${attr(\"class\", colCls[i])}>${esc(colLabels[i])}${mark(\"column_labels\", cols[i])}</th>`);\n out.push(`</tr></thead>`);\n\n // Body footnote markers\n const bodyMarks = {};\n for (const f of fns) {\n if (f.location.type !== \"body\") continue;\n const fi = reg.idx[f.text];\n eachLoc(f.location.rows, f.location.columns || [], key => { bodyMarks[key] = fi; });\n }\n\n // Row rendering\n const pushRow = r => {\n out.push(`<tr>`);\n // Rowspan group cells\n for (const rs of rowSpans) {\n const span = rs.spans[r - 1];\n if (span > 0) out.push(`<th scope=\"row\" class=\"lt-row-group\"${attr(\"rowspan\", span > 1 ? span : 0)}>${esc(cell(rs.col, r))}</th>`);\n }\n const ind = indent[r - 1] || 0;\n for (let ci = 0; ci < cols.length; ci++) {\n const k = `${r},${ci}`,\n m = bodyMarks[k], cc = classMap[k],\n cls = [colCls[ci], cc].filter(Boolean).join(\" \");\n let s = styleMap[k] || \"\";\n if (ci === 0 && ind) s = (s ? s + \";\" : \"\") + `padding-left:${ind + 1}em`;\n out.push(`<td${attr(\"class\", cls)}${attr(\"style\", s)}>${esc(cell(cols[ci], r))}${m ? sup(m) : \"\"}</td>`);\n }\n out.push(`</tr>`);\n };\n\n // <tbody>\n out.push(`<tbody>`);\n if (groups.length) {\n const seen = {};\n for (const g of groups) {\n out.push(`<tr class=\"lt-row-group\"><th colspan=\"${nCol}\" scope=\"colgroup\">${esc(g.label)}${mark(\"row_groups\", g.label)}</th></tr>`);\n for (const r of g.rows) { seen[r] = 1; pushRow(r); }\n }\n for (let r = 1; r <= nRow; r++) if (!seen[r]) pushRow(r);\n } else {\n for (let r = 1; r <= nRow; r++) pushRow(r);\n }\n out.push(`</tbody>`);\n\n // <tfoot>: footnotes then source notes, each a full-width row.\n if (reg.order.length || notes.length) {\n const footRow = (cls, html) => `<tr class=\"${cls}\"><td colspan=\"${nCol}\">${html}</td></tr>`;\n out.push(`<tfoot class=\"lt-footer\">`);\n reg.order.forEach((txt, i) => out.push(footRow(\"lt-footnote\", `${sup(i + 1)} ${esc(txt)}`)));\n for (const n of notes) out.push(footRow(\"lt-source-note\", esc(n)));\n out.push(`</tfoot>`);\n }\n\n out.push(`</table>`);\n return out.join(\"\");\n }\n\n const mount = (s, spec) => s.insertAdjacentHTML(\"afterend\", buildHtml(spec));\n // q.push renders immediately; replay any entries queued before we loaded.\n const q = { push: e => mount(e.s, e.d) };\n (root.LT?.q || []).forEach(q.push);\n root.LT = { build: spec => mount(document.currentScript, spec), buildHtml, q };\n})(window);\n</script>\n</body></html>"
Entry point of the lightweight grammar of tables. Returns an object (a
list) that records the data plus a list of table-modifying operations. The
object is rendered to HTML by format() (called automatically by the
print method).
lt(data, ...)
## Default S3 method:
lt(data, auto_fmt = TRUE, ...)
data |
A data frame (or anything coercible to one). |
... |
Arguments passed to methods. |
auto_fmt |
Whether to automatically format numeric columns (rounding,
thousand separators, percentage detection). Set to |
A table object that can be piped into lt_*() functions.
lt(head(mtcars[, 1:4]))
Override the auto-detected alignment for specific columns. By default, numeric columns are right-aligned and character columns are left-aligned.
lt_align(x, columns, align = c("left", "center", "right"))
x |
An |
columns |
Character vector of column names (or a one-sided formula). |
align |
One of |
x with the alignment recorded.
lt(head(mtcars)) |> lt_align(~ cyl + gear, "center")
Add user-supplied stylesheets or inline rules that render after the built-in CSS, so rules can override the defaults.
lt_css(x, ...)
x |
An |
... |
Unnamed arguments are stylesheet paths or URLs. A bare
filename (no directory component) that does not exist in the working
directory is resolved against the stylesheets shipped with lt, so
e.g. Named arguments define inline CSS rules scoped to |
x with the stylesheets recorded.
tbl = lt(head(mtcars))
tbl |>
lt_style("mpg", test = "v => v > 20", class = "high") |>
lt_css(.high = list(background = "#cfc", fontWeight = "bold"))
Attaches a footnote text to a table region. Footnotes are numbered
automatically in the order they are added (de-duplicated by text).
lt_footnote(x, text, where, columns = NULL, rows = NULL, match = NULL)
x |
An |
text |
Footnote text. |
where |
One of |
columns |
Character vector of column names or a one-sided formula (for
|
rows |
Integer vector of 1-based row indices (for |
match |
For |
x with the footnote recorded.
lt(head(mtcars)) |>
lt_footnote("Source: 1974 Motor Trend US magazine.", "title")
Control the number of decimal places and thousands separator for numeric
columns. Columns passed to this function are excluded from automatic
formatting (see the auto_fmt argument of lt()). To disable auto-format
for a column without otherwise changing its display, call lt_format(x, ~col) with no other arguments.
lt_format(x, columns, decimals = NULL, big_mark = NULL, percent = NULL)
x |
An |
columns |
Character or integer vector of columns (or a one-sided formula). |
decimals |
Number of decimal places (default |
big_mark |
Thousands separator (e.g., |
percent |
If |
x with the formatting recorded.
lt(head(mtcars)) |> lt_format(~ mpg + wt, decimals = 1, big_mark = ",")
Partition rows into labeled groups. Pass column names to group by those
columns' values (the columns are removed from the body and rendered as
rowspan cells on the left). Use sep = TRUE to render groups as
full-width separator rows instead of rowspan.
lt_group(x, ..., sep = "auto", sort = TRUE)
x |
An |
... |
A column name or formula (e.g., |
sep |
If |
sort |
If |
x with the row groups recorded.
# Group by a column (rowspan, default)
d = data.frame(arm = c("Placebo", "Placebo", "Treatment", "Treatment"),
stat = c("n", "Mean", "n", "Mean"), value = c(30, 4.2, 31, 6.8))
lt(d) |> lt_group(~ arm)
# Separator-row style
lt(d) |> lt_group(~ arm, sep = TRUE)
# Manual groups (always separator rows)
lt(head(mtcars)) |>
lt_group("First three" = 1:3, "Last three" = 4:6)
Add a Title and Subtitle
lt_header(x, title = NULL, subtitle = NULL)
x |
An |
title |
A character scalar. |
subtitle |
A character scalar. |
x with the header recorded.
lt(head(mtcars)) |> lt_header("Motor Trend Cars", "First 6 rows")
Add hierarchical indentation to the first column of specified rows.
lt_indent(x, rows, level = 1)
x |
An |
rows |
Integer vector of 1-based row indices to indent. |
level |
Indent level (default 1). Each level adds one unit of left padding. |
x with the indentation recorded.
d = data.frame(label = c("Overall", "Male", "Female"), n = c(100, 55, 45))
lt(d) |> lt_indent(2:3)
Override column headers without modifying the underlying data frame.
lt_label(x, ...)
x |
An |
... |
Named arguments of the form |
x with the column label overrides recorded.
lt(head(mtcars)) |> lt_label(mpg = "Miles/Gallon", cyl = "Cylinders")
Combine values from multiple columns into a single display column using a pattern. Source columns (all except the first) are hidden by default.
lt_merge(x, columns, pattern = NULL, hide = TRUE)
x |
An |
columns |
Character vector of column names (or a one-sided formula). The first column is the target (receives merged content); the rest are sources. |
pattern |
A glue-style template using |
hide |
If |
x with the merge recorded.
d = data.frame(stat = c("Mean", "SD"), value = c(4.2, 1.1), ci = c("(2.0, 6.4)", "(0.5, 1.7)"))
lt(d) |> lt_merge(~ value + ci, pattern = "{1} {2}")
Rearrange column display order without modifying the data frame.
lt_move(x, columns, after = NULL)
x |
An |
columns |
Character vector of column names (or a one-sided formula). |
after |
Column name after which to place the moved columns. Use
|
x with the column move recorded.
lt(head(mtcars)) |> lt_move(~ gear + carb, after = "mpg")
Notes are rendered in the table footer below numbered footnotes.
lt_note(x, text)
x |
An |
text |
Note text. |
x with the note recorded.
lt(head(mtcars)) |> lt_note("CI = confidence interval.")
lt_output() creates a UI placeholder; render_lt() supplies the table
spec from the server. Together they render an lt() table as a custom
Shiny output — no renderUI() involved.
lt_output(outputId, ...)
render_lt(expr, env = parent.frame(), quoted = FALSE)
outputId |
Output variable name to read the table from. |
... |
Reserved for future use. |
expr |
An expression that returns an |
env |
Environment in which to evaluate |
quoted |
Whether |
lt_output() returns a Shiny UI element; render_lt() returns a
render function.
if (interactive()) {
library(shiny)
ui = fluidPage(lt_output("tbl"))
server = function(input, output) {
output$tbl = render_lt(lt(head(mtcars)) |> lt_header("Motor Trend"))
}
shinyApp(ui, server)
}
A spanner is a label rendered above a contiguous group of column headers.
lt_spanner(x, label, columns, sep = "[._]")
x |
An |
label |
A character scalar — the spanner text. Alternatively, a
two-sided formula |
columns |
Column names (character or formula). When missing, inferred from column names. |
sep |
Separator pattern for auto-inference (default |
When called with no label or columns, infers spanners from column
names by splitting on the first . or _ separator. Contiguous columns
sharing a prefix are grouped under that prefix, and column labels are
shortened to the suffix.
x with the spanner recorded.
The columns must be contiguous in the body of the table.
tbl = lt(head(iris))
# Explicit spanner
tbl |> lt_spanner(Sepal ~ Sepal.Length + Sepal.Width)
# Auto-infer from column names
tbl |> lt_spanner()
Apply CSS styling to specific cells. Target cells by column, row, or both.
When test is provided, styles are applied conditionally based on cell
values (evaluated in JavaScript).
lt_style(
x,
columns = NULL,
rows = NULL,
test = NULL,
class = NULL,
bold = NULL,
italic = NULL,
color = NULL,
bg = NULL,
...
)
x |
An |
columns |
Character vector of column names, a one-sided formula, or
|
rows |
Integer vector of 1-based row indices (or |
test |
A JavaScript function as a string (e.g., |
class |
CSS class name(s) to add to matching cells. Define the
corresponding rules in an external stylesheet via |
bold |
Logical: apply bold weight? |
italic |
Logical: apply italic style? |
color |
Text color (any CSS color value, e.g., |
bg |
Background color. |
... |
Additional CSS properties as named arguments. Names can be
camelCase (e.g., |
x with the style recorded.
tbl = lt(head(mtcars))
tbl |>
lt_style("mpg", rows = 1L, bold = TRUE, borderBottom = "2px solid red")
tbl |>
lt_style("mpg", test = "v => v > 20", class = "high") |>
lt_css(.high = list(background = "#cfc"))
Replace NA, zero, or small values with display text.
lt_sub(
x,
columns = NULL,
missing = NULL,
zero = NULL,
small = NULL,
small_text = NULL
)
x |
An |
columns |
Character vector of column names, a one-sided formula, or
|
missing |
Replacement for |
zero |
Replacement for zero values (e.g., |
small |
Threshold: values whose absolute value is below this are
replaced by |
small_text |
Text shown for values below |
x with the substitution recorded.
d = data.frame(x = c(1, 0, NA, 0.001))
lt(d) |> lt_sub(missing = "—", zero = "—", small = 0.01, small_text = "<0.01")
Set Column Widths
lt_width(x, ...)
x |
An |
... |
Named arguments of the form |
x with the column widths recorded.
lt(head(mtcars)) |> lt_width(mpg = "100px", cyl = "50px")
lt_tbl (Opens in the Viewer or Browser)Print an lt_tbl (Opens in the Viewer or Browser)
## S3 method for class 'lt_tbl'
print(x, ...)
x |
An |
... |
Passed to |
x, invisibly.
print(lt(head(mtcars)))