564 lines
17 KiB
Lua
564 lines
17 KiB
Lua
-- latex-tables.lua — write tables in LaTeX, render them in HTML too.
|
|
--
|
|
-- \begin{tabular}{|l|c|r|}
|
|
-- \hline
|
|
-- Quantity & Symbol & Unit \\
|
|
-- \hline
|
|
-- pressure & $p$ & Pa \\
|
|
-- density & $\rho$ & kg/m$^3$ \\
|
|
-- \hline
|
|
-- \end{tabular}
|
|
--
|
|
-- Supported: l c r, p{w} m{w} b{w}, X (tabularx), *{n}{...} repetition,
|
|
-- @{...} and >{...}<{...} (ignored), | vertical rules, \hline, \cline{a-b},
|
|
-- booktabs \toprule \midrule \bottomrule \cmidrule{a-b}, \multicolumn,
|
|
-- \multirow, and the \begin{table} float wrapper with \caption and \label.
|
|
--
|
|
-- PDF: passed through untouched (it is already LaTeX).
|
|
-- HTML: converted to a real <table>; cell contents are parsed as LaTeX, so
|
|
-- $math$, \textbf{...} etc. work.
|
|
--
|
|
-- Header rows: everything above the first rule that follows row 1 becomes
|
|
-- <thead>. Put "% no-header" inside the environment to disable that.
|
|
|
|
local ENVS = { tabular = true, tabularx = true, longtable = true,
|
|
["tabular*"] = true }
|
|
|
|
local function fmt_is(name)
|
|
if quarto and quarto.doc and quarto.doc.is_format then
|
|
return quarto.doc.is_format(name)
|
|
end
|
|
return FORMAT ~= nil and FORMAT:match(name) ~= nil
|
|
end
|
|
|
|
------------------------------------------------------------------ utils
|
|
|
|
local function trim(s)
|
|
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
|
|
end
|
|
|
|
-- index of the "}" matching the "{" at open_idx
|
|
local function find_close(s, open_idx)
|
|
local depth, i, n = 0, open_idx, #s
|
|
while i <= n do
|
|
local c = s:sub(i, i)
|
|
if c == "\\" then
|
|
i = i + 2
|
|
else
|
|
if c == "{" then
|
|
depth = depth + 1
|
|
elseif c == "}" then
|
|
depth = depth - 1
|
|
if depth == 0 then return i end
|
|
end
|
|
i = i + 1
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- split on every literal `sep` sitting at brace depth 0 and outside any
|
|
-- nested environment (so \\ and & inside \begin{cases}/{array}/{pmatrix}
|
|
-- in a cell are left alone)
|
|
local function split_top(s, sep)
|
|
local parts, depth, env, i, start = {}, 0, 0, 1, 1
|
|
local n, k = #s, #sep
|
|
while i <= n do
|
|
if depth == 0 and env == 0 and s:sub(i, i + k - 1) == sep then
|
|
parts[#parts + 1] = s:sub(start, i - 1)
|
|
i = i + k
|
|
start = i
|
|
else
|
|
local c = s:sub(i, i)
|
|
if c == "\\" then
|
|
if s:find("^\\begin%s*{", i) then
|
|
env = env + 1
|
|
i = i + 6
|
|
elseif s:find("^\\end%s*{", i) then
|
|
env = math.max(0, env - 1)
|
|
i = i + 4
|
|
else
|
|
i = i + 2
|
|
end
|
|
else
|
|
if c == "{" then
|
|
depth = depth + 1
|
|
elseif c == "}" then
|
|
depth = math.max(0, depth - 1)
|
|
end
|
|
i = i + 1
|
|
end
|
|
end
|
|
end
|
|
parts[#parts + 1] = s:sub(start)
|
|
return parts
|
|
end
|
|
|
|
-- strip % comments (but keep \%)
|
|
local function strip_comments(s)
|
|
local out, i, n = {}, 1, #s
|
|
while i <= n do
|
|
local c = s:sub(i, i)
|
|
if c == "\\" then
|
|
out[#out + 1] = s:sub(i, i + 1)
|
|
i = i + 2
|
|
elseif c == "%" then
|
|
local nl = s:find("\n", i, true)
|
|
if nl == nil then break end
|
|
i = nl -- keep the newline
|
|
else
|
|
out[#out + 1] = c
|
|
i = i + 1
|
|
end
|
|
end
|
|
return table.concat(out)
|
|
end
|
|
|
|
-- LaTeX length -> CSS length
|
|
local function css_len(w)
|
|
w = trim(w)
|
|
local frac, rel = w:match("^([%d%.]+)\\(%a+)$")
|
|
if frac ~= nil and (rel == "textwidth" or rel == "linewidth"
|
|
or rel == "columnwidth") then
|
|
return string.format("%.4g%%", tonumber(frac) * 100)
|
|
end
|
|
if w:match("^\\%a+$") then return "100%" end
|
|
local num, unit = w:match("^([%-%d%.]+)%s*(%a+)$")
|
|
if num ~= nil then
|
|
if unit == "pt" then return num .. "pt" end
|
|
if unit == "cm" or unit == "mm" or unit == "in"
|
|
or unit == "em" or unit == "ex" then
|
|
return num .. unit
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
------------------------------------------------------------------ colspec
|
|
|
|
-- *{3}{c|} -> c|c|c|
|
|
local function expand_star(spec)
|
|
local guard = 0
|
|
while guard < 20 do
|
|
guard = guard + 1
|
|
local st, _, cnt, body = spec:find("%*%s*(%b{})%s*(%b{})")
|
|
if st == nil then break end
|
|
local n = tonumber(cnt:sub(2, -2))
|
|
local inner = body:sub(2, -2)
|
|
if n == nil then break end
|
|
spec = spec:sub(1, st - 1) .. string.rep(inner, n)
|
|
.. spec:sub(st + #cnt + #body + (spec:sub(st + 1, st + 1) == " " and 1 or 0))
|
|
-- recompute safely: rebuild from the matched span
|
|
local _, en = spec:find(inner, st, true)
|
|
if en == nil then break end
|
|
end
|
|
return spec
|
|
end
|
|
|
|
-- returns list of { align, left, right, width }
|
|
local function parse_colspec(spec)
|
|
spec = expand_star(spec)
|
|
local cols, pending, i, n = {}, 0, 1, #spec
|
|
while i <= n do
|
|
local c = spec:sub(i, i)
|
|
if c == "|" then
|
|
pending = pending + 1
|
|
i = i + 1
|
|
elseif c == " " or c == "\n" or c == "\t" then
|
|
i = i + 1
|
|
elseif c == "@" or c == "!" or c == ">" or c == "<" then
|
|
local br = spec:find("{", i, true)
|
|
local close = br and find_close(spec, br)
|
|
i = close and (close + 1) or (i + 1)
|
|
elseif c == "l" or c == "c" or c == "r" then
|
|
cols[#cols + 1] = { align = c, left = pending, right = 0 }
|
|
pending = 0
|
|
i = i + 1
|
|
elseif c == "p" or c == "m" or c == "b" then
|
|
local br = spec:find("{", i, true)
|
|
local close = br and find_close(spec, br)
|
|
local w = close and spec:sub(br + 1, close - 1) or nil
|
|
cols[#cols + 1] = { align = "l", left = pending, right = 0,
|
|
width = w and css_len(w) or nil }
|
|
pending = 0
|
|
i = close and (close + 1) or (i + 1)
|
|
elseif c == "X" then
|
|
cols[#cols + 1] = { align = "l", left = pending, right = 0, width = "auto" }
|
|
pending = 0
|
|
i = i + 1
|
|
else
|
|
i = i + 1
|
|
end
|
|
end
|
|
if #cols > 0 and pending > 0 then cols[#cols].right = pending end
|
|
return cols
|
|
end
|
|
|
|
------------------------------------------------------------------ rows
|
|
|
|
local RULE_PAT = {
|
|
{ pat = "^\\toprule%s*(%b[])?", kind = "thick" },
|
|
{ pat = "^\\toprule", kind = "thick" },
|
|
{ pat = "^\\bottomrule", kind = "thick" },
|
|
{ pat = "^\\midrule", kind = "thin" },
|
|
{ pat = "^\\hline", kind = "thin" },
|
|
}
|
|
|
|
-- pull leading rule commands off a row segment
|
|
local function take_rules(seg)
|
|
local rules = { all = nil, ranges = {}, count = 0 }
|
|
local changed = true
|
|
while changed do
|
|
changed = false
|
|
seg = seg:gsub("^%s+", "")
|
|
-- \noalign{...} / \addlinespace / spacing args: drop
|
|
local br = seg:match("^\\noalign%s*%b{}")
|
|
if br then seg = seg:sub(#br + 1); changed = true end
|
|
local al = seg:match("^\\addlinespace%s*%b[]") or seg:match("^\\addlinespace")
|
|
if al then seg = seg:sub(#al + 1); changed = true end
|
|
local opt = seg:match("^%b[]")
|
|
if opt then seg = seg:sub(#opt + 1); changed = true end
|
|
for _, r in ipairs(RULE_PAT) do
|
|
local m = seg:match(r.pat)
|
|
if m then
|
|
rules.count = rules.count + 1
|
|
rules.all = (rules.all == "thick" or r.kind == "thick") and "thick" or "thin"
|
|
if rules.count > 1 then rules.all = "double" end
|
|
seg = seg:sub(#m + 1)
|
|
changed = true
|
|
break
|
|
end
|
|
end
|
|
-- \cline{a-b} and \cmidrule(lr){a-b}
|
|
local head, range = seg:match("^(\\cline%s*(%b{}))")
|
|
if head == nil then
|
|
local h2, _, r2 = seg:match("^(\\cmidrule%s*(%b())%s*(%b{}))")
|
|
if h2 then head, range = h2, r2 end
|
|
end
|
|
if head == nil then
|
|
local h3, r3 = seg:match("^(\\cmidrule%s*(%b{}))")
|
|
if h3 then head, range = h3, r3 end
|
|
end
|
|
if head ~= nil and range ~= nil then
|
|
local a, b = range:sub(2, -2):match("^%s*(%d+)%s*%-%s*(%d+)%s*$")
|
|
if a then
|
|
rules.ranges[#rules.ranges + 1] = { tonumber(a), tonumber(b) }
|
|
end
|
|
seg = seg:sub(#head + 1)
|
|
changed = true
|
|
end
|
|
end
|
|
return seg, rules
|
|
end
|
|
|
|
local function has_rule(rules)
|
|
return rules.all ~= nil or #rules.ranges > 0
|
|
end
|
|
|
|
-- \multicolumn{n}{spec}{content} / \multirow{n}{w}{content}
|
|
local function parse_cell(txt)
|
|
local cell = { colspan = 1, rowspan = 1, body = txt, spec = nil }
|
|
local t = trim(txt)
|
|
local head, a, b = t:match("^(\\multicolumn%s*(%b{})%s*(%b{}))")
|
|
if head then
|
|
local br = t:find("{", #head + 1, true)
|
|
local close = br and find_close(t, br)
|
|
if close then
|
|
cell.colspan = tonumber(a:sub(2, -2)) or 1
|
|
cell.spec = b:sub(2, -2)
|
|
cell.body = t:sub(br + 1, close - 1)
|
|
t = trim(t:sub(close + 1))
|
|
if t ~= "" then cell.body = cell.body .. " " .. t end
|
|
return cell
|
|
end
|
|
end
|
|
local mhead, mn = t:match("^(\\multirow%s*(%b{}))")
|
|
if mhead then
|
|
-- optional [vpos], then {width}{content}
|
|
local rest = t:sub(#mhead + 1)
|
|
local opt = rest:match("^%b[]")
|
|
if opt then rest = rest:sub(#opt + 1) end
|
|
local w = rest:match("^%s*%b{}")
|
|
if w then
|
|
rest = rest:sub(#rest:match("^%s*") + #w + 1)
|
|
local br = rest:find("{", 1, true)
|
|
local close = br and find_close(rest, br)
|
|
if close then
|
|
cell.rowspan = tonumber(mn:sub(2, -2)) or 1
|
|
cell.body = rest:sub(br + 1, close - 1)
|
|
return cell
|
|
end
|
|
end
|
|
end
|
|
return cell
|
|
end
|
|
|
|
local function parse_body(body)
|
|
local rows, trailing = {}, nil
|
|
local segs = split_top(body, "\\\\")
|
|
for idx, raw in ipairs(segs) do
|
|
local seg, rules = take_rules(raw)
|
|
seg = trim(seg)
|
|
if seg == "" then
|
|
if idx == #segs then
|
|
trailing = rules
|
|
elseif #rows > 0 and has_rule(rules) then
|
|
-- a rule on its own line between rows: attach below previous row
|
|
rows[#rows].below = rules
|
|
end
|
|
else
|
|
local cells = {}
|
|
for _, c in ipairs(split_top(seg, "&")) do
|
|
cells[#cells + 1] = parse_cell(c)
|
|
end
|
|
rows[#rows + 1] = { cells = cells, above = rules }
|
|
end
|
|
end
|
|
return rows, trailing
|
|
end
|
|
|
|
------------------------------------------------------------------ html
|
|
|
|
local function cell_inlines(txt)
|
|
txt = trim(txt)
|
|
if txt == "" then return pandoc.Inlines({}) end
|
|
local ok, doc = pcall(pandoc.read, txt, "latex")
|
|
if ok and doc and #doc.blocks > 0 then
|
|
return pandoc.utils.blocks_to_inlines(doc.blocks)
|
|
end
|
|
return pandoc.Inlines({ pandoc.Str(txt) })
|
|
end
|
|
|
|
local function rule_class(kind, where)
|
|
if kind == "thick" then return "ltx-" .. where .. "2" end
|
|
if kind == "double" then return "ltx-" .. where .. "d" end
|
|
if kind == "thin" then return "ltx-" .. where .. "1" end
|
|
return nil
|
|
end
|
|
|
|
local ALIGN = { l = "AlignLeft", c = "AlignCenter", r = "AlignRight" }
|
|
|
|
-- build one pandoc.Row.
|
|
-- `pending` maps column -> number of further rows still covered by a
|
|
-- \multirow above. LaTeX writes an empty placeholder cell for those, but
|
|
-- HTML's rowspan already covers them, so they must be consumed, not emitted.
|
|
local function build_row(cols, row, ri, nrows, trailing, pending)
|
|
local cells = pandoc.List({})
|
|
local ci = 1
|
|
local input, k = row.cells, 1
|
|
while k <= #input do
|
|
-- skip columns still covered from above
|
|
while (pending[ci] or 0) > 0 do
|
|
pending[ci] = pending[ci] - 1
|
|
ci = ci + 1
|
|
if trim(input[k].body) == "" and input[k].colspan == 1
|
|
and input[k].rowspan == 1 then
|
|
k = k + 1 -- eat the placeholder
|
|
if k > #input then break end
|
|
end
|
|
end
|
|
if k > #input then break end
|
|
local cell = input[k]
|
|
k = k + 1
|
|
local col = cols[ci] or { align = "l", left = 0, right = 0 }
|
|
local last = cols[math.min(ci + cell.colspan - 1, #cols)] or col
|
|
local align, left, right = col.align, col.left, last.right
|
|
local width = col.width
|
|
if cell.spec ~= nil then
|
|
local sub = parse_colspec(cell.spec)
|
|
if #sub > 0 then
|
|
align = sub[1].align
|
|
left = sub[1].left
|
|
right = sub[#sub].right
|
|
end
|
|
end
|
|
local cls = pandoc.List({ "ltx-" .. align })
|
|
if left > 0 then cls:insert("ltx-bl") end
|
|
if right > 0 then cls:insert("ltx-br") end
|
|
|
|
local ra = row.above
|
|
if ra.all then
|
|
cls:insert(rule_class(ra.all, "t"))
|
|
else
|
|
for _, rg in ipairs(ra.ranges) do
|
|
if ci >= rg[1] and ci <= rg[2] then cls:insert("ltx-t1") break end
|
|
end
|
|
end
|
|
local rb = row.below or (ri == nrows and trailing or nil)
|
|
if rb then
|
|
if rb.all then
|
|
cls:insert(rule_class(rb.all, "b"))
|
|
else
|
|
for _, rg in ipairs(rb.ranges) do
|
|
if ci >= rg[1] and ci <= rg[2] then cls:insert("ltx-b1") break end
|
|
end
|
|
end
|
|
end
|
|
|
|
local attrs = {}
|
|
if width and width ~= "auto" then attrs.style = "width:" .. width end
|
|
local content = pandoc.Blocks({ pandoc.Plain(cell_inlines(cell.body)) })
|
|
cells:insert(pandoc.Cell(content, ALIGN[align] or "AlignDefault",
|
|
cell.rowspan, cell.colspan, pandoc.Attr("", cls, attrs)))
|
|
if cell.rowspan > 1 then
|
|
for c = ci, ci + cell.colspan - 1 do
|
|
pending[c] = (pending[c] or 0) + cell.rowspan - 1
|
|
end
|
|
end
|
|
ci = ci + cell.colspan
|
|
end
|
|
return pandoc.Row(cells)
|
|
end
|
|
|
|
local function render_table(cols, rows, trailing, header_end, id, caption)
|
|
local colspecs = pandoc.List({})
|
|
for _, c in ipairs(cols) do
|
|
colspecs:insert({ ALIGN[c.align] or "AlignDefault", nil })
|
|
end
|
|
|
|
local head_rows, body_rows = pandoc.List({}), pandoc.List({})
|
|
local pending = {}
|
|
for ri, row in ipairs(rows) do
|
|
local r = build_row(cols, row, ri, #rows, trailing, pending)
|
|
if header_end and ri <= header_end then
|
|
head_rows:insert(r)
|
|
else
|
|
body_rows:insert(r)
|
|
end
|
|
end
|
|
|
|
local cap = pandoc.Caption({})
|
|
if caption ~= nil then
|
|
cap = pandoc.Caption(pandoc.Blocks({ pandoc.Plain(cell_inlines(caption)) }))
|
|
end
|
|
|
|
return pandoc.Table(
|
|
cap,
|
|
colspecs,
|
|
pandoc.TableHead(head_rows),
|
|
{ { attr = pandoc.Attr(), body = body_rows,
|
|
head = pandoc.List({}), row_head_columns = 0 } },
|
|
pandoc.TableFoot(),
|
|
pandoc.Attr(id or "", { "ltx-table" })
|
|
)
|
|
end
|
|
|
|
------------------------------------------------------------------ transform
|
|
|
|
local function find_env(txt, name)
|
|
local st = txt:find("\\begin%s*{" .. name .. "}")
|
|
if st == nil then return nil end
|
|
local _, be = txt:find("\\begin%s*{" .. name .. "}", st)
|
|
local es, ee = txt:find("\\end%s*{" .. name .. "}", be)
|
|
if es == nil then return nil end
|
|
return st, be, es, ee
|
|
end
|
|
|
|
local function tabular_to_table(txt, id, caption)
|
|
local name
|
|
for env in pairs(ENVS) do
|
|
if txt:find("\\begin%s*{" .. env:gsub("%*", "%%*") .. "}") then
|
|
name = env
|
|
break
|
|
end
|
|
end
|
|
if name == nil then return nil end
|
|
local esc_name = name:gsub("%*", "%%*")
|
|
local _, be, es = find_env(txt, esc_name)
|
|
if be == nil then return nil end
|
|
local rest = txt:sub(be + 1)
|
|
local inner_end = es - be - 1
|
|
local inner = rest:sub(1, inner_end)
|
|
|
|
-- tabularx/tabular* take a width argument first
|
|
if name == "tabularx" or name == "tabular*" then
|
|
local w = inner:match("^%s*%b{}")
|
|
if w then inner = inner:sub(#inner:match("^%s*") + #w + 1) end
|
|
end
|
|
-- optional [t]/[b] positioning
|
|
local pos = inner:match("^%s*%b[]")
|
|
if pos then inner = inner:sub(#inner:match("^%s*") + #pos + 1) end
|
|
|
|
local specm = inner:match("^%s*%b{}")
|
|
if specm == nil then return nil end
|
|
local body = inner:sub(#inner:match("^%s*") + #specm + 1)
|
|
local spec = specm:sub(2, -2)
|
|
|
|
local no_header = body:find("no%-header") ~= nil
|
|
body = strip_comments(body)
|
|
local cols = parse_colspec(spec)
|
|
if #cols == 0 then return nil end
|
|
local rows, trailing = parse_body(body)
|
|
if #rows == 0 then return nil end
|
|
|
|
local header_end = 0
|
|
if not no_header then
|
|
for i = 2, #rows do
|
|
if has_rule(rows[i].above) or (rows[i - 1].below and has_rule(rows[i - 1].below)) then
|
|
header_end = i - 1
|
|
break
|
|
end
|
|
end
|
|
end
|
|
return render_table(cols, rows, trailing, header_end, id, caption)
|
|
end
|
|
|
|
-- \begin{table} ... \caption{...} \label{...} ... \end{table}
|
|
local function float_parts(txt)
|
|
if not txt:find("\\begin%s*{table%*?}") then return nil end
|
|
local cap = nil
|
|
local cs = txt:find("\\caption")
|
|
if cs then
|
|
local br = txt:find("{", cs, true)
|
|
local close = br and find_close(txt, br)
|
|
if close then cap = txt:sub(br + 1, close - 1) end
|
|
end
|
|
local lab = txt:match("\\label%s*{(.-)}")
|
|
return { caption = cap, label = lab }
|
|
end
|
|
|
|
local function RawBlock(el)
|
|
if el.format ~= "tex" and el.format ~= "latex" then return nil end
|
|
local txt = el.text
|
|
local has_tab = false
|
|
for env in pairs(ENVS) do
|
|
if txt:find("\\begin%s*{" .. env:gsub("%*", "%%*") .. "}") then
|
|
has_tab = true
|
|
break
|
|
end
|
|
end
|
|
if not has_tab then return nil end
|
|
if fmt_is("latex") then return nil end -- PDF: it is already LaTeX
|
|
if not fmt_is("html") then return nil end
|
|
|
|
local float = float_parts(txt) or {}
|
|
local tbl = tabular_to_table(txt, float.label, float.caption)
|
|
if tbl == nil then
|
|
io.stderr:write("[latex-tables] could not parse a tabular; left as-is\n")
|
|
return nil
|
|
end
|
|
return tbl
|
|
end
|
|
|
|
local function read_meta(meta)
|
|
if quarto ~= nil and quarto.doc ~= nil then
|
|
if fmt_is("html") then
|
|
quarto.doc.add_html_dependency({
|
|
name = "calc-latex-tables",
|
|
version = "1.0.0",
|
|
stylesheets = { "latex-tables.css" },
|
|
})
|
|
elseif fmt_is("latex") then
|
|
quarto.doc.use_latex_package("booktabs") -- \toprule etc.
|
|
quarto.doc.use_latex_package("multirow") -- \multirow
|
|
quarto.doc.use_latex_package("array")
|
|
end
|
|
end
|
|
return meta
|
|
end
|
|
|
|
return {
|
|
{ Meta = read_meta },
|
|
{ RawBlock = RawBlock },
|
|
}
|