init commit (base extensions: notes)

This commit is contained in:
2026-09-14 07:59:32 +02:00
parent b0b859d20b
commit 7178558330
16 changed files with 2130 additions and 0 deletions
+563
View File
@@ -0,0 +1,563 @@
-- 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 },
}