Help pages

-- 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()

1 lt: Lightweight Tables via JSON Specs and JavaScript

Description

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.

Author(s)

Maintainer: Yihui Xie [email protected] (ORCID) [copyright holder]

Authors:

See Also

Useful links:

2 Render an lt_tbl to HTML

Description

Emits 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.

Usage

## S3 method for class 'lt_tbl'
format(x, fragment = TRUE, inline_assets = TRUE, assets = TRUE, ...)

Arguments

x

An lt_tbl object.

fragment

If TRUE (default), return an HTML fragment suitable for embedding. If FALSE, wrap in a minimal <html><body> document.

inline_assets

If TRUE (default), inline the CSS/JS as text. If FALSE, emit <link> / <script src=...> tags (assets must be served alongside the HTML).

assets

If TRUE (default), include the CSS+JS runtime. Pass FALSE to emit only the spec block when the runtime is already on the page.

...

Reserved for future use.

Value

A character scalar containing HTML.

Examples

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, \"&amp;\").replace(/[<]/g, \"&lt;\")\n    .replace(/>/g, \"&gt;\").replace(/\"/g, \"&quot;\");\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>"

3 Create a Table Specification

Description

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).

Usage

lt(data, ...)

## Default S3 method:
lt(data, auto_fmt = TRUE, ...)

Arguments

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 FALSE to disable for the whole table; use lt_format() on specific columns to disable selectively.

Value

A table object that can be piped into lt_*() functions.

Examples

lt(head(mtcars[, 1:4]))

4 Set Column Alignment

Description

Override the auto-detected alignment for specific columns. By default, numeric columns are right-aligned and character columns are left-aligned.

Usage

lt_align(x, columns, align = c("left", "center", "right"))

Arguments

x

An lt() object.

columns

Character vector of column names (or a one-sided formula).

align

One of "left", "center", or "right".

Value

x with the alignment recorded.

Examples

lt(head(mtcars)) |> lt_align(~ cyl + gear, "center")

5 Attach Custom CSS

Description

Add user-supplied stylesheets or inline rules that render after the built-in CSS, so rules can override the defaults.

Usage

lt_css(x, ...)

Arguments

x

An lt() object.

...

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. lt_css(x, "lt-gt.css") uses the bundled gt-like theme.

Named arguments define inline CSS rules scoped to .lt-table. Names are selectors (e.g., .na, td.highlight) and values are either a CSS string or a named list of properties: lt_css(x, .na = "background: #eee") or lt_css(x, .na = list(background = "#eee")).

Value

x with the stylesheets recorded.

Examples

tbl = lt(head(mtcars))
tbl |>
  lt_style("mpg", test = "v => v > 20", class = "high") |>
  lt_css(.high = list(background = "#cfc", fontWeight = "bold"))

6 Add a Footnote

Description

Attaches a footnote text to a table region. Footnotes are numbered automatically in the order they are added (de-duplicated by text).

Usage

lt_footnote(x, text, where, columns = NULL, rows = NULL, match = NULL)

Arguments

x

An lt() object.

text

Footnote text.

where

One of 'title', 'subtitle', 'column', 'spanner', 'group', or 'body'.

columns

Character vector of column names or a one-sided formula (for 'column' or 'body'). For 'group' with match = "starts_with", a single prefix string.

rows

Integer vector of 1-based row indices (for 'body'; NULL means all rows).

match

For where = "group": one of "exact" (default), "starts_with", or "all".

Value

x with the footnote recorded.

Examples

lt(head(mtcars)) |>
  lt_footnote("Source: 1974 Motor Trend US magazine.", "title")

7 Format Numeric Columns

Description

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.

Usage

lt_format(x, columns, decimals = NULL, big_mark = NULL, percent = NULL)

Arguments

x

An lt() object.

columns

Character or integer vector of columns (or a one-sided formula).

decimals

Number of decimal places (default NULL means no change).

big_mark

Thousands separator (e.g., ","). NULL or "" means none.

percent

If TRUE, multiply values by 100 and append "%". If "%", only append "%" without multiplying (for values already in percent scale).

Value

x with the formatting recorded.

Examples

lt(head(mtcars)) |> lt_format(~ mpg + wt, decimals = 1, big_mark = ",")

8 Define Row Groups

Description

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.

Usage

lt_group(x, ..., sep = "auto", sort = TRUE)

Arguments

x

An lt() object.

...

A column name or formula (e.g., ~col or ~col1 + col2) to group by column values, or named arguments of the form "Label" = rows (integer vector of 1-based row indices) for manual groups. Unnamed character strings reorder previously defined groups.

sep

If TRUE, render groups as full-width separator rows instead of the default rowspan style. Only supports a single grouping column. The default 'auto' uses separator rows when there is a single grouping column with any value longer than 20 characters.

sort

If TRUE (default), sort rows by group columns so that identical group values are contiguous. Set to FALSE to preserve the original row order.

Value

x with the row groups recorded.

Examples

# 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)

9 Add a Title and Subtitle

Description

Add a Title and Subtitle

Usage

lt_header(x, title = NULL, subtitle = NULL)

Arguments

x

An lt() object.

title

A character scalar.

subtitle

A character scalar.

Value

x with the header recorded.

Examples

lt(head(mtcars)) |> lt_header("Motor Trend Cars", "First 6 rows")

10 Indent Rows

Description

Add hierarchical indentation to the first column of specified rows.

Usage

lt_indent(x, rows, level = 1)

Arguments

x

An lt() object.

rows

Integer vector of 1-based row indices to indent.

level

Indent level (default 1). Each level adds one unit of left padding.

Value

x with the indentation recorded.

Examples

d = data.frame(label = c("Overall", "Male", "Female"), n = c(100, 55, 45))
lt(d) |> lt_indent(2:3)

11 Rename Column Labels

Description

Override column headers without modifying the underlying data frame.

Usage

lt_label(x, ...)

Arguments

x

An lt() object.

...

Named arguments of the form col_name = "Display Label".

Value

x with the column label overrides recorded.

Examples

lt(head(mtcars)) |> lt_label(mpg = "Miles/Gallon", cyl = "Cylinders")

12 Merge Columns

Description

Combine values from multiple columns into a single display column using a pattern. Source columns (all except the first) are hidden by default.

Usage

lt_merge(x, columns, pattern = NULL, hide = TRUE)

Arguments

x

An lt() object.

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 \{1\}, \{2\}, etc. to refer to columns by position. Wrap sections in << and >> for conditional NA handling: "\{1\}<< (\{2\})>>" drops the wrapped portion when any referenced value is missing/empty. If NULL, columns are concatenated separated by spaces.

hide

If TRUE (default), source columns (all but the first) are automatically hidden.

Value

x with the merge recorded.

Examples

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}")

13 Move Columns

Description

Rearrange column display order without modifying the data frame.

Usage

lt_move(x, columns, after = NULL)

Arguments

x

An lt() object.

columns

Character vector of column names (or a one-sided formula).

after

Column name after which to place the moved columns. Use NULL to move to the start.

Value

x with the column move recorded.

Examples

lt(head(mtcars)) |> lt_move(~ gear + carb, after = "mpg")

14 Add a Note

Description

Notes are rendered in the table footer below numbered footnotes.

Usage

lt_note(x, text)

Arguments

x

An lt() object.

text

Note text.

Value

x with the note recorded.

Examples

lt(head(mtcars)) |> lt_note("CI = confidence interval.")

15 Shiny Bindings for lt

Description

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.

Usage

lt_output(outputId, ...)

render_lt(expr, env = parent.frame(), quoted = FALSE)

Arguments

outputId

Output variable name to read the table from.

...

Reserved for future use.

expr

An expression that returns an lt() object.

env

Environment in which to evaluate expr.

quoted

Whether expr is already quoted.

Value

lt_output() returns a Shiny UI element; render_lt() returns a render function.

Examples

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)
}

16 Add a Column Spanner

Description

A spanner is a label rendered above a contiguous group of column headers.

Usage

lt_spanner(x, label, columns, sep = "[._]")

Arguments

x

An lt() object.

label

A character scalar — the spanner text. Alternatively, a two-sided formula Label ~ col1 + col2 providing both the label (LHS) and columns (RHS). When missing, spanners are inferred from column names.

columns

Column names (character or formula). When missing, inferred from column names.

sep

Separator pattern for auto-inference (default "[._]").

Details

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.

Value

x with the spanner recorded.

Note

The columns must be contiguous in the body of the table.

Examples

tbl = lt(head(iris))
# Explicit spanner
tbl |> lt_spanner(Sepal ~ Sepal.Length + Sepal.Width)
# Auto-infer from column names
tbl |> lt_spanner()

17 Style Cells

Description

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).

Usage

lt_style(
  x,
  columns = NULL,
  rows = NULL,
  test = NULL,
  class = NULL,
  bold = NULL,
  italic = NULL,
  color = NULL,
  bg = NULL,
  ...
)

Arguments

x

An lt() object.

columns

Character vector of column names, a one-sided formula, or NULL for all.

rows

Integer vector of 1-based row indices (or NULL for all).

test

A JavaScript function as a string (e.g., "v => v < 0") that receives the raw cell value and returns true to apply the style. When NULL, the style applies unconditionally.

class

CSS class name(s) to add to matching cells. Define the corresponding rules in an external stylesheet via lt_css().

bold

Logical: apply bold weight?

italic

Logical: apply italic style?

color

Text color (any CSS color value, e.g., "red", "#06c").

bg

Background color.

...

Additional CSS properties as named arguments. Names can be camelCase (e.g., borderLeft) or quoted dash-case (e.g., `border-left`). Values are CSS strings.

Value

x with the style recorded.

Examples

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"))

18 Substitute Cell Values

Description

Replace NA, zero, or small values with display text.

Usage

lt_sub(
  x,
  columns = NULL,
  missing = NULL,
  zero = NULL,
  small = NULL,
  small_text = NULL
)

Arguments

x

An lt() object.

columns

Character vector of column names, a one-sided formula, or NULL for all.

missing

Replacement for NA cells (e.g., "—"). NULL to leave NAs as empty strings (the default rendering).

zero

Replacement for zero values (e.g., "—").

small

Threshold: values whose absolute value is below this are replaced by small_text.

small_text

Text shown for values below small (e.g., "<0.1").

Value

x with the substitution recorded.

Examples

d = data.frame(x = c(1, 0, NA, 0.001))
lt(d) |> lt_sub(missing = "—", zero = "—", small = 0.01, small_text = "<0.01")

19 Set Column Widths

Description

Set Column Widths

Usage

lt_width(x, ...)

Arguments

x

An lt() object.

...

Named arguments of the form col_name = "width". Width can be any CSS value (e.g., "100px", "20%", "8em").

Value

x with the column widths recorded.

Examples

lt(head(mtcars)) |> lt_width(mpg = "100px", cyl = "50px")

20 Print an lt_tbl (Opens in the Viewer or Browser)

Description

Print an lt_tbl (Opens in the Viewer or Browser)

Usage

## S3 method for class 'lt_tbl'
print(x, ...)

Arguments

x

An lt_tbl object.

...

Passed to format().

Value

x, invisibly.

Examples

print(lt(head(mtcars)))