Change folder hierarchy

This commit is contained in:
2026-09-14 08:04:43 +02:00
parent 7178558330
commit d457cf20c5
12 changed files with 0 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
title: notes
author: Matsune
version: 1.0.0
quarto-required: ">=1.3.0"
contributes:
filters:
- calc-notes.lua
- glossary.lua
- exo.lua
- latex-tables.lua
+90
View File
@@ -0,0 +1,90 @@
/* calc-notes — HTML layout for the calc / calc* environments */
.calc {
display: grid;
/* lhs | rhs | notes */
grid-template-columns: max-content max-content minmax(8rem, 1fr);
column-gap: 0.35rem;
row-gap: 0.6rem;
align-items: baseline;
margin: 1rem 0 1.4rem;
/* allow horizontal scrolling for very wide equations, but never show a
vertical bar (Firefox computes phantom vertical overflow otherwise) */
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 0.4em; /* keep descenders clear of a horizontal bar */
}
.calc-row { display: contents; }
.calc-lhs { grid-column: 1; justify-self: end; }
.calc-rhs { grid-column: 2; justify-self: start; }
.calc-full { grid-column: 1 / 3; justify-self: start; }
/* ---- notes ------------------------------------------------------- */
.calc-notes {
grid-column: 3;
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.75rem;
align-items: baseline;
min-width: 0;
padding-left: 0.9rem;
}
.calc-note {
color: var(--calc-notecolor, #B45309);
font-size: 0.85em;
line-height: 1.35;
}
.calc-note > summary {
cursor: pointer;
list-style: none; /* remove the default triangle ... */
user-select: none;
opacity: 0.7;
font-style: italic;
}
.calc-note > summary::-webkit-details-marker { display: none; }
.calc-note > summary::before { content: "▸ "; font-style: normal; }
.calc-note[open] > summary::before { content: "▾ "; }
.calc-note > summary:hover { opacity: 1; }
.calc-note[open] {
background: color-mix(in srgb, var(--calc-notecolor, #B45309) 8%, transparent);
border-left: 2px solid var(--calc-notecolor, #B45309);
border-radius: 0.25rem;
padding: 0.15rem 0.45rem;
}
.calc-note-body { margin-top: 0.1rem; }
.calc-note-body p { margin: 0; }
/* \mnote: math note (no extra styling needed — body is typeset math) */
.calc-note-math .calc-note-body { padding: 0.1rem 0; }
/* ---- calc*: boxed final result ----------------------------------- */
.calc-box {
display: inline-block;
border: 1.5px solid currentColor;
border-radius: 0.3em;
padding: 0.3em 0.65em;
margin-top: 0.15em;
background: color-mix(in srgb, currentColor 4%, transparent);
}
/* ---- printing: show notes unfolded, like the PDF ------------------ */
@media print {
.calc-note > summary { display: none; }
.calc-note[open] { background: none; border: none; padding: 0; }
}
/* small screens: notes drop under the equation line */
@media (max-width: 560px) {
.calc { grid-template-columns: max-content 1fr; }
.calc-notes { grid-column: 1 / 3; padding-left: 1.5rem; }
.calc-full { grid-column: 1 / 3; }
}
+15
View File
@@ -0,0 +1,15 @@
// calc-notes — open every note before printing, restore afterwards
(function () {
window.addEventListener("beforeprint", function () {
document.querySelectorAll("details.calc-note").forEach(function (d) {
d.dataset.calcWasOpen = d.open ? "1" : "0";
d.open = true;
});
});
window.addEventListener("afterprint", function () {
document.querySelectorAll("details.calc-note").forEach(function (d) {
d.open = d.dataset.calcWasOpen === "1";
delete d.dataset.calcWasOpen;
});
});
})();
+327
View File
@@ -0,0 +1,327 @@
-- calc-notes.lua
-- "calc" / "calc*" derivation environments for Quarto (HTML + PDF).
--
-- \begin{calc}
-- f(x) &= (x+1)^2 \note{expand the square}\\
-- &= x^2 + 2x + 1 \mnote{$(a+b)^2 = a^2+2ab+b^2$}
-- \end{calc}
--
-- \note{...} -> inline note next to the line (foldable in HTML, plain colored text in PDF)
-- \mnote{...} -> math note: content is raw math, no $...$ needed; may contain
-- aligned lines like "f &= ma \\\\ &= m\\dot{v}"
-- calc* -> the last line is boxed to highlight the final result
--
-- Rename the environment here if you want something other than "calc":
local ENV = "calc"
local DEFAULT_COLOR = "#B45309" -- overridden by `notecolor:` in the document metadata
local notecolor = DEFAULT_COLOR
------------------------------------------------------------------ utils
local function trim(s)
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
end
-- Works both under Quarto and plain pandoc.
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
-- Split `s` on every occurrence of the literal `sep` that sits at brace
-- depth 0 AND outside any nested environment, so neither "\\" inside
-- \text{...} nor the row/column separators of a nested \begin{pmatrix},
-- \begin{cases}, \begin{array}... ever split a calc line.
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 -- skip escaped char / start of a control sequence
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
-- s:sub(open_idx) == "{"; returns index of the matching "}".
local function find_balanced_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
-- Remove every \cmd{...} from `line`; return (cleaned line, {contents...}).
local function extract_cmd(line, cmd)
local found = {}
while true do
local st, brace = line:find("\\" .. cmd .. "%s*{")
if not st then break end
local close = find_balanced_close(line, brace)
if not close then break end
found[#found + 1] = line:sub(brace + 1, close - 1)
line = line:sub(1, st - 1) .. line:sub(close + 1)
end
return line, found
end
-- \mnote content is raw math; wrap multi-line/aligned content in aligned.
-- `opt` is the aligned position option ("[t]" for LaTeX, "" for MathJax).
local function wrap_mnote(txt, opt)
if #split_top(txt, "\\\\") > 1 or #split_top(txt, "&") > 1 then
return "\\begin{aligned}" .. opt .. " " .. txt .. " \\end{aligned}"
end
return txt
end
------------------------------------------------------------------ parse
local function parse_body(body)
local lines = {}
for _, raw in ipairs(split_top(body, "\\\\")) do
local l = trim(raw)
-- drop an optional spacing argument left over from "\\[1ex]"
l = trim(l:gsub("^%[[^%]]*%]", ""))
if l ~= "" then
local m, notes = extract_cmd(l, "note")
local m2, mnotes = extract_cmd(m, "mnote")
lines[#lines + 1] = { math = trim(m2), notes = notes, mnotes = mnotes }
end
end
return lines
end
------------------------------------------------------------------ HTML
-- Note contents are parsed as LaTeX so `$...$` inside a note renders as math.
local function note_inlines(txt)
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 details_blocks(content, kind)
local b = pandoc.Blocks({})
b:insert(pandoc.RawBlock("html",
'<details class="calc-note calc-note-' .. kind .. '"><summary>note</summary><div class="calc-note-body">'))
if kind == "math" then
-- \mnote content is raw math — no $...$ needed
b:insert(pandoc.Plain {
pandoc.Math("InlineMath", wrap_mnote(content, ""))
})
else
b:insert(pandoc.Plain(note_inlines(content)))
end
b:insert(pandoc.RawBlock("html", "</div></details>"))
return b
end
local function math_div(cls, tex)
return pandoc.Div(
pandoc.Plain { pandoc.Math("InlineMath", "\\displaystyle " .. tex) },
pandoc.Attr("", { cls }))
end
local function html_env(lines, star)
local rows = pandoc.Blocks({})
for idx, L in ipairs(lines) do
local final = star and idx == #lines
local cells = pandoc.Blocks({})
if final then
-- boxed final result: render the whole line (alignment "&" stripped)
local flat = trim(table.concat(split_top(L.math, "&"), " "))
cells:insert(pandoc.Div(
pandoc.Plain {
pandoc.RawInline("html", '<span class="calc-box">'),
pandoc.Math("InlineMath", "\\displaystyle {} " .. flat),
pandoc.RawInline("html", "</span>"),
},
pandoc.Attr("", { "calc-full" })))
else
local parts = split_top(L.math, "&")
if #parts == 1 then
cells:insert(math_div("calc-full", parts[1]))
else
local lhs = trim(parts[1])
local rhs = trim(table.concat(parts, " ", 2))
if lhs ~= "" then
cells:insert(math_div("calc-lhs", lhs))
end
-- leading "{}" keeps correct spacing for a line starting with "= ..."
cells:insert(math_div("calc-rhs", "{} " .. rhs))
end
end
local nb = pandoc.Blocks({})
for _, t in ipairs(L.notes) do nb:extend(details_blocks(t, "inline")) end
for _, t in ipairs(L.mnotes) do nb:extend(details_blocks(t, "math")) end
if #nb > 0 then
cells:insert(pandoc.Div(nb, pandoc.Attr("", { "calc-notes" })))
end
local rowcls = { "calc-row" }
if final then rowcls[#rowcls + 1] = "calc-final" end
rows:insert(pandoc.Div(cells, pandoc.Attr("", rowcls)))
end
local cls = star and { "calc", "calc-star" } or { "calc" }
return pandoc.Blocks { pandoc.Div(rows, pandoc.Attr("", cls)) }
end
------------------------------------------------------------------ LaTeX
local function latex_env(lines, star)
local out = {}
for idx, L in ipairs(lines) do
local seg = L.math
if star and idx == #lines then
if #split_top(seg, "&") > 1 then
seg = "\\Aboxed{" .. seg .. "}" -- boxes across the alignment point (mathtools)
else
seg = "\\boxed{" .. seg .. "}"
end
end
for _, t in ipairs(L.notes) do
seg = seg .. " \\quad {\\color{notecolor}\\text{\\small " .. t .. "}}"
end
for _, t in ipairs(L.mnotes) do
-- math note: content is math already; \text{\small$..$} scales it down
seg = seg .. " \\quad {\\color{notecolor}\\text{\\small$"
.. wrap_mnote(t, "[t]") .. "$}}"
end
out[#out + 1] = " " .. seg
end
return pandoc.Blocks {
pandoc.RawBlock("latex",
"\\begin{align*}\n" .. table.concat(out, " \\\\\n") .. "\n\\end{align*}")
}
end
------------------------------------------------------------------ filter
local function transform(txt)
local star, body = txt:match(
"^%s*\\begin{" .. ENV .. "(%*?)}(.-)\\end{" .. ENV .. "%*?}%s*$")
if body == nil then return nil end
local lines = parse_body(body)
if #lines == 0 then return nil end
local isstar = (star == "*")
if fmt_is("latex") then
return latex_env(lines, isstar)
elseif fmt_is("html") then
return html_env(lines, isstar)
end
return nil
end
function RawBlock(el)
if el.format ~= "tex" and el.format ~= "latex" then return nil end
return transform(el.text)
end
-- also catch the env when it's wrapped in $$ ... $$
-- The math may share its paragraph with other text (a line touching the
-- fence, a trailing character, an inline "$$...$$" in a sentence). Split
-- the paragraph: text before/after stays as text, the env becomes blocks.
local function is_ws(it)
return it.t == "Space" or it.t == "SoftBreak" or it.t == "LineBreak"
or (it.t == "Str" and it.text:match("^%s*$") ~= nil)
end
local function flush(cur, out, ctor)
local only_ws = true
for _, it in ipairs(cur) do
if not is_ws(it) then only_ws = false; break end
end
if not only_ws then out:insert(ctor(cur)) end
end
local function block_with_math(el, ctor)
local out = pandoc.Blocks({})
local cur = pandoc.Inlines({})
local found = false
for _, it in ipairs(el.content) do
local res = nil
if it.t == "Math" and it.mathtype == "DisplayMath" then
res = transform(it.text)
end
if res ~= nil then
found = true
flush(cur, out, ctor)
cur = pandoc.Inlines({})
out:extend(res)
else
cur:insert(it)
end
end
if not found then return nil end
flush(cur, out, ctor)
return out
end
function Para(el) return block_with_math(el, pandoc.Para) end
function Plain(el) return block_with_math(el, pandoc.Plain) end
function Meta(m)
if m.notecolor then
notecolor = pandoc.utils.stringify(m.notecolor)
end
if quarto == nil or quarto.doc == nil then return end -- plain pandoc: skip injection
if fmt_is("html") then
quarto.doc.add_html_dependency({
name = "calc-notes",
version = "1.0.0",
stylesheets = { "calc-notes.css" },
scripts = { "calc-notes.js" },
})
quarto.doc.include_text("in-header",
"<style>:root { --calc-notecolor: " .. notecolor .. "; }</style>")
elseif fmt_is("latex") then
quarto.doc.use_latex_package("mathtools") -- \Aboxed
quarto.doc.use_latex_package("xcolor")
local hex = notecolor:gsub("^#", "")
quarto.doc.include_text("in-header", table.concat({
"\\definecolor{notecolor}{HTML}{" .. hex .. "}",
}, "\n"))
end
end
+57
View File
@@ -0,0 +1,57 @@
/* exo — exercise cards, hint folds (sequential unlock), solution fold */
.exo {
margin: 1.2rem 0;
padding: 0.6rem 0.9rem;
border: 1px solid color-mix(in srgb, var(--calc-notecolor, #B45309) 35%, transparent);
border-left: 3px solid var(--calc-notecolor, #B45309);
border-radius: 0.35rem;
scroll-margin-top: 5rem;
}
.exo-head p { margin: 0 0 0.4rem; }
.exo-head strong { color: var(--calc-notecolor, #B45309); }
.exo details {
margin: 0.45rem 0;
border-radius: 0.3rem;
}
.exo details > summary {
cursor: pointer;
list-style: none;
user-select: none;
font-size: 0.9em;
font-weight: 600;
}
.exo details > summary::-webkit-details-marker { display: none; }
.exo details > summary::before { content: "▸ "; }
.exo details[open] > summary::before { content: "▾ "; }
/* hints: small highlight boxes */
.exo-hint {
background: color-mix(in srgb, var(--calc-notecolor, #B45309) 6%, transparent);
border: 1px solid color-mix(in srgb, var(--calc-notecolor, #B45309) 30%, transparent);
padding: 0.25rem 0.6rem;
}
.exo-hint > summary { color: var(--calc-notecolor, #B45309); }
/* locked hints: unlock one by one */
.exo-hint.exo-locked > summary {
cursor: not-allowed;
opacity: 0.45;
}
.exo-hint.exo-locked > summary::before { content: "🔒 "; }
/* solution: closed by default, visually separated */
.exo-sol {
border-top: 1px dashed color-mix(in srgb, var(--calc-notecolor, #B45309) 40%, transparent);
padding: 0.35rem 0.1rem 0.1rem;
}
.exo-fold-body { margin-top: 0.3rem; }
.exo-fold-body > p:first-child { margin-top: 0; }
.exo-fold-body > p:last-child { margin-bottom: 0; }
@media print {
.exo details > summary::before { content: ""; }
.exo-hint.exo-locked > summary { opacity: 1; cursor: default; }
}
+33
View File
@@ -0,0 +1,33 @@
// exo — hints unlock one by one; everything unfolds for printing
(function () {
// block opening a locked hint
document.addEventListener("click", function (e) {
var s = e.target.closest ? e.target.closest("details.exo-hint.exo-locked > summary") : null;
if (s) e.preventDefault();
});
// opening hint n unlocks hint n+1 (toggle doesn't bubble: use capture)
document.addEventListener("toggle", function (e) {
var d = e.target;
if (!d.classList || !d.classList.contains("exo-hint") || !d.open) return;
var next = d.nextElementSibling;
while (next && !(next.tagName === "DETAILS" && next.classList.contains("exo-hint"))) {
next = next.nextElementSibling;
}
if (next) next.classList.remove("exo-locked");
}, true);
// print: open all hints/solutions, restore afterwards
window.addEventListener("beforeprint", function () {
document.querySelectorAll(".exo details").forEach(function (d) {
d.dataset.exoWasOpen = d.open ? "1" : "0";
d.open = true;
});
});
window.addEventListener("afterprint", function () {
document.querySelectorAll(".exo details").forEach(function (d) {
d.open = d.dataset.exoWasOpen === "1";
delete d.dataset.exoWasOpen;
});
});
})();
+237
View File
@@ -0,0 +1,237 @@
-- exo.lua — numbered exercise environments for Quarto (HTML + PDF)
--
-- :::: {.exo title="Divergence practice"}
-- Statement, figures, anything...
--
-- ::: {.hint}
-- First hint.
-- :::
--
-- ::: {.hint}
-- Second hint.
-- :::
--
-- ::: {.solution}
-- Full solution.
-- :::
-- ::::
--
-- Numbering is chapter.n where "chapter" counts level-1 headers (#) and n
-- resets per chapter (1.1, 1.2, 2.1, ...). Without any level-1 header the
-- number is just n.
--
-- HTML: hints are numbered folds that unlock one by one; the solution is a
-- fold, closed by default.
-- PDF: the statement stays in place, hints become small highlight boxes,
-- solutions are collected into an unnumbered "Solutions" section at
-- the end of each chapter, cross-linked both ways with page refs.
--
-- Labels are configurable via metadata:
-- exo:
-- label: "Exercice"
-- hint: "Indice"
-- solution: "Solution"
-- solutions: "Solutions"
local labels = {
label = "Exercise",
hint = "Hint",
solution = "Solution",
solutions = "Solutions",
}
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
------------------------------------------------------------------ meta
local function read_meta(meta)
if meta.exo ~= nil then
for k in pairs(labels) do
if meta.exo[k] ~= nil then
labels[k] = pandoc.utils.stringify(meta.exo[k])
end
end
end
if quarto ~= nil and quarto.doc ~= nil then
if fmt_is("html") then
quarto.doc.add_html_dependency({
name = "calc-exo",
version = "1.0.0",
stylesheets = { "exo.css" },
scripts = { "exo.js" },
})
elseif fmt_is("latex") then
quarto.doc.use_latex_package("tcolorbox")
end
end
return meta
end
------------------------------------------------------------------ pieces
-- Quarto pre-processes ::: {.solution} (and .proof/.remark) into an
-- internal custom node before user filters run. Inside an .exo we take
-- such a node as the solution and unwrap its scaffold to get the body.
local function custom_proof_body(div)
for _, sc in ipairs(div.content) do
if sc.t == "Div" and sc.attributes.__quarto_custom_scaffold ~= nil
and #sc.content > 0 then
local inner = sc.content
if #inner == 1 and inner[1].t == "Div" and #inner[1].classes == 0 then
return inner[1].content
end
return inner
end
end
return div.content
end
local function split_exo(el)
local stmt, hints, sol = pandoc.Blocks({}), {}, nil
for _, b in ipairs(el.content) do
if b.t == "Div" and b.classes:includes("hint") then
hints[#hints + 1] = b.content
elseif b.t == "Div" and (b.classes:includes("solution")
or b.classes:includes("sol")) then
sol = b.content
elseif b.t == "Div" and b.attributes.__quarto_custom_type == "Proof" then
sol = custom_proof_body(b)
else
stmt:insert(b)
end
end
return stmt, hints, sol
end
local function head_inlines(num, title)
local ins = pandoc.Inlines({ pandoc.Str(labels.label .. " " .. num) })
if title ~= nil then
ins:insert(pandoc.Str("\u{2002}\u{2002}"))
ins:insert(pandoc.Emph(pandoc.Inlines({ pandoc.Str(title) })))
end
return ins
end
------------------------------------------------------------------ html
local function html_exo(num, title, stmt, hints, sol)
local blocks = pandoc.Blocks({})
blocks:insert(pandoc.Div(
pandoc.Blocks({ pandoc.Para(pandoc.Inlines({
pandoc.Strong(head_inlines(num, title)) })) }),
pandoc.Attr("", { "exo-head" })))
blocks:extend(stmt)
for i, h in ipairs(hints) do
local locked = (i > 1) and " exo-locked" or ""
blocks:insert(pandoc.RawBlock("html",
'<details class="exo-hint' .. locked .. '"><summary>'
.. labels.hint .. " " .. i .. '</summary><div class="exo-fold-body">'))
blocks:extend(h)
blocks:insert(pandoc.RawBlock("html", "</div></details>"))
end
if sol ~= nil then
blocks:insert(pandoc.RawBlock("html",
'<details class="exo-sol"><summary>' .. labels.solution
.. '</summary><div class="exo-fold-body">'))
blocks:extend(sol)
blocks:insert(pandoc.RawBlock("html", "</div></details>"))
end
return pandoc.Div(blocks, pandoc.Attr("exo-" .. num, { "exo" }))
end
------------------------------------------------------------------ latex
local function latex_exo(num, title, stmt, hints, sol)
local out = pandoc.Blocks({})
local head = pandoc.Inlines({
pandoc.RawInline("latex", "\\hypertarget{exo-" .. num .. "}{}"),
pandoc.Strong(head_inlines(num, title)),
})
out:insert(pandoc.Para(head))
out:extend(stmt)
for i, h in ipairs(hints) do
out:insert(pandoc.RawBlock("latex",
"\\begin{tcolorbox}[colback=notecolor!5!white,colframe=notecolor!40!white,colbacktitle=notecolor!15!white,"
.. "coltitle=black,fonttitle=\\small\\bfseries,title={" .. labels.hint
.. " " .. i .. "},left=1.5mm,right=1.5mm,top=1mm,bottom=1mm]"))
out:extend(h)
out:insert(pandoc.RawBlock("latex", "\\end{tcolorbox}"))
end
local solblocks = nil
if sol ~= nil then
out:insert(pandoc.Para(pandoc.Inlines({
pandoc.RawInline("latex",
"{\\small\\itshape\\hyperlink{sol-" .. num .. "}{" .. labels.solution
.. " " .. num .. " $\\to$ p.\\,\\pageref{sol:" .. num .. "}}}"),
})))
solblocks = pandoc.Blocks({ pandoc.Para(pandoc.Inlines({
pandoc.RawInline("latex",
"\\hypertarget{sol-" .. num .. "}{}\\label{sol:" .. num .. "}"),
pandoc.Strong(pandoc.Inlines({ pandoc.Str(labels.solution .. " " .. num) })),
pandoc.Str("\u{2002}"),
pandoc.RawInline("latex",
"{\\small(\\hyperlink{exo-" .. num .. "}{$\\leftarrow$ "
.. labels.label .. " " .. num .. "})}"),
})) })
solblocks:extend(sol)
end
return out, solblocks
end
------------------------------------------------------------------ walk
local function walk(doc)
local is_latex = fmt_is("latex")
local is_html = fmt_is("html")
local chapter, cnt = 0, 0
local pending = {}
local out = pandoc.Blocks({})
local function flush()
if #pending == 0 then return end
out:insert(pandoc.Header(2,
pandoc.Inlines({ pandoc.Str(labels.solutions) }),
pandoc.Attr("", { "unnumbered" })))
for _, s in ipairs(pending) do out:extend(s) end
pending = {}
end
for _, b in ipairs(doc.blocks) do
if b.t == "Header" and b.level == 1 then
if is_latex then flush() end
if not b.classes:includes("unnumbered") then -- prefaces etc. don't count
chapter = chapter + 1
cnt = 0
end
out:insert(b)
elseif b.t == "Div" and b.classes:includes("exo") then
cnt = cnt + 1
local num = chapter > 0 and (chapter .. "." .. cnt) or tostring(cnt)
local stmt, hints, sol = split_exo(b)
if is_latex then
local inplace, solb = latex_exo(num, b.attributes.title, stmt, hints, sol)
out:extend(inplace)
if solb ~= nil then pending[#pending + 1] = solb end
elseif is_html then
out:insert(html_exo(num, b.attributes.title, stmt, hints, sol))
else
out:insert(b)
end
else
out:insert(b)
end
end
if is_latex then flush() end
doc.blocks = out
return doc
end
return {
{ Meta = read_meta },
{ Pandoc = walk },
}
+77
View File
@@ -0,0 +1,77 @@
/* glossary — usages, entries, hover tooltip */
/* usages: dotted underline, keep math color */
a.gls-sym,
mjx-container a {
color: inherit;
text-decoration: none;
}
.gls-sym {
border-bottom: 1px dotted currentColor;
cursor: help;
}
/* rendered glossary list */
.glossary .gls-entry {
margin: 0.8rem 0;
padding: 0.35rem 0.7rem;
border-left: 3px solid var(--calc-notecolor, #B45309);
scroll-margin-top: 5rem; /* don't hide behind Quarto's sticky header */
}
.glossary .gls-entry p { margin: 0 0 0.2rem; }
.glossary .gls-entry:target {
background: color-mix(in srgb, var(--calc-notecolor, #B45309) 10%, transparent);
border-radius: 0.25rem;
}
/* floating hover tooltip (filled by glossary.js) */
.gls-float {
position: absolute;
z-index: 1000;
max-width: 26rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--calc-notecolor, #B45309);
border-radius: 0.4rem;
background: var(--bs-body-bg, #fff);
color: var(--bs-body-color, #1a1a1a);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15);
font-size: 0.9em;
line-height: 1.4;
}
.gls-float p { margin: 0 0 0.25rem; }
.gls-float p:last-child { margin-bottom: 0; }
/* in-place definition cards and property sub-entries */
.gls-inline {
margin: 1rem 0;
padding: 0.4rem 0.8rem;
border-left: 3px solid var(--calc-notecolor, #B45309);
scroll-margin-top: 5rem;
}
.gls-inline p { margin: 0 0 0.3rem; }
.gls-inline:target,
.gls-prop-entry:target {
background: color-mix(in srgb, var(--calc-notecolor, #B45309) 10%, transparent);
border-radius: 0.25rem;
}
.gls-prop-entry {
margin: 0.5rem 0 0.5rem 1.2rem;
padding: 0.25rem 0.6rem;
border-left: 2px solid color-mix(in srgb, var(--calc-notecolor, #B45309) 45%, transparent);
font-size: 0.95em;
scroll-margin-top: 5rem;
}
.gls-back {
font-size: 0.85em;
opacity: 0.75;
}
/* standalone property cards keep the section flow (no indent) */
.gls-prop-entry.gls-standalone {
margin-left: 0;
font-size: 1em;
}
.glossary .gls-prop-line {
margin: 0.1rem 0 0.1rem 1.2rem;
font-size: 0.9em;
}
+68
View File
@@ -0,0 +1,68 @@
// glossary — hover tooltip for .gls-sym usages (prose links and MathJax \class nodes)
(function () {
var tip = null;
function ensureTip() {
if (tip === null) {
tip = document.createElement("div");
tip.className = "gls-float";
tip.style.display = "none";
document.body.appendChild(tip);
}
return tip;
}
function keyOf(el) {
if (el.dataset && el.dataset.glsKey) return el.dataset.glsKey;
for (var i = 0; i < el.classList.length; i++) {
var c = el.classList[i];
if (c.indexOf("gls-key-") === 0) return c.slice(8);
}
return null;
}
function show(target) {
var key = keyOf(target);
if (!key) return;
var entry = document.getElementById("gls-" + key);
var t = ensureTip();
if (entry) {
// for a main entry, don't drag every property into the popup
var node = entry.cloneNode(true);
node.querySelectorAll(".gls-prop-entry").forEach(function (n) { n.remove(); });
t.innerHTML = node.innerHTML;
} else if (window.__glsData && window.__glsData[key]) {
// entry lives on another page (book chapters render separately)
t.innerHTML = window.__glsData[key];
} else {
return;
}
t.style.display = "block";
if (window.MathJax && window.MathJax.typesetPromise) {
window.MathJax.typesetPromise([t]).catch(function () {});
}
var r = target.getBoundingClientRect();
var x = r.left + window.scrollX;
var y = r.bottom + window.scrollY + 6;
// keep it on screen horizontally
var w = t.offsetWidth;
var maxX = window.scrollX + document.documentElement.clientWidth - w - 12;
t.style.left = Math.max(window.scrollX + 8, Math.min(x, maxX)) + "px";
t.style.top = y + "px";
}
function hide() {
if (tip !== null) tip.style.display = "none";
}
document.addEventListener("mouseover", function (e) {
var el = e.target.closest ? e.target.closest(".gls-sym") : null;
if (el) show(el);
});
document.addEventListener("mouseout", function (e) {
var el = e.target.closest ? e.target.closest(".gls-sym") : null;
if (el) hide();
});
document.addEventListener("click", hide);
window.addEventListener("scroll", hide, { passive: true });
})();
+550
View File
@@ -0,0 +1,550 @@
-- glossary.lua — clickable/hoverable glossary for Quarto (HTML + PDF)
--
-- Entries can be defined two ways:
--
-- (1) in metadata (document or _quarto.yml), rendered by the glossary list:
--
-- glossary:
-- laplacian:
-- symbol: "$\\nabla^2$"
-- term: "Laplacian"
-- def: "Divergence of the gradient."
-- props: # optional sub-entries
-- linearity:
-- term: "linearity"
-- def: "$\\nabla^2(af+bg) = a\\nabla^2 f + b\\nabla^2 g$"
--
-- (2) inline, at the natural place in the document flow:
--
-- :::: {.gls-def key=laplacian symbol="$\nabla^2$" term="Laplacian"}
-- Divergence of the gradient: $\nabla^2 f = \nabla\cdot\nabla f$.
--
-- ::: {.gls-prop key=linearity term="linearity"}
-- $\nabla^2(af + bg) = a\,\nabla^2 f + b\,\nabla^2 g$.
-- :::
-- ::::
--
-- The div renders in place as the definition card AND registers the
-- entry; usages link back to this spot.
--
-- Usage (works for entries and properties):
-- in math: \gls{laplacian} \gls{laplacian.linearity}
-- in prose: [laplacian]{.gls} [by linearity]{.gls key=laplacian.linearity}
-- the list: ::: {.glossary}\n:::
--
-- HTML: usages are links with hover popups. PDF: hyperref links.
local glossary = {} -- key -> entry
local keys = {} -- sorted list of top-level keys
local glossary_page = nil -- e.g. "glossary.html": where metadata entries live
-- entry = { sym, term = Inlines, def = Blocks, inline = bool,
-- props = { pkey -> {sym, term = Inlines, def = Blocks} },
-- porder = { pkey... } }
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
------------------------------------------------------------------ helpers
-- first Math element's text within a metadata value / inline list
local function math_of(v)
if v == nil then return nil end
local inlines = v
if pandoc.utils.type(v) == "Blocks" then
inlines = pandoc.utils.blocks_to_inlines(v)
end
for _, it in ipairs(inlines) do
if it.t == "Math" then return it.text end
end
return nil
end
-- math text out of an attribute string like "$\nabla^2$" (or bare "\nabla^2")
local function math_of_attr(s)
if s == nil then return nil end
local inner = s:match("^%s*%$(.-)%$%s*$")
return inner or s
end
local function to_inlines(v)
if v == nil then return pandoc.Inlines({}) end
if pandoc.utils.type(v) == "Blocks" then
return pandoc.utils.blocks_to_inlines(v)
end
return v
end
local function to_blocks(v)
if v == nil then return pandoc.Blocks({}) end
if pandoc.utils.type(v) == "Blocks" then return v end
return pandoc.Blocks({ pandoc.Plain(v) })
end
local function anchor(key) return "gls-" .. key end
-- resolve "key" or "key.prop"; returns display info + anchor or nil
-- Entries defined inline live on this page; entries that come from
-- metadata live wherever the ::: {.glossary} list is rendered. In a book
-- each chapter is a separate HTML page, so those need a page-qualified
-- link (set `glossary-page:` in metadata).
local function href_of(base, id)
local e = glossary[base]
if e ~= nil and e.inline then return "#" .. id end
if glossary_page ~= nil and fmt_is("html") then
return glossary_page .. "#" .. id
end
return "#" .. id
end
local function resolve(full)
local base, prop = full:match("^([^%.]+)%.(.+)$")
base = base or full
local e = glossary[base]
if e == nil then return nil end
local id = anchor(full)
if prop ~= nil then
local p = e.props[prop]
if p == nil then return nil end
return { sym = p.sym, term = p.term, id = id, href = href_of(base, id) }
end
return { sym = e.sym, term = e.term, id = id, href = href_of(base, id) }
end
-- what \gls{...} displays in math mode
local function math_display(r)
return r.sym or ("\\text{" .. pandoc.utils.stringify(r.term) .. "}")
end
local function warn_key(key)
io.stderr:write("[glossary] unknown key: " .. key
.. " (if it is defined with ::: {.gls-def} in another chapter, note that"
.. " HTML book chapters render separately - define shared terms in"
.. " metadata / glossary.yml instead)\n")
end
------------------------------------------------------------------ pass 1: metadata
local function parse_meta_entry(v)
local entry = {
sym = math_of(v.symbol),
term = to_inlines(v.term),
def = to_blocks(v.def),
inline = false,
props = {},
porder = {},
}
if v.props ~= nil then
for pk, pv in pairs(v.props) do
entry.props[pk] = {
sym = math_of(pv.symbol),
term = #to_inlines(pv.term) > 0 and to_inlines(pv.term)
or pandoc.Inlines({ pandoc.Str(pk) }),
def = to_blocks(pv.def),
}
entry.porder[#entry.porder + 1] = pk
end
table.sort(entry.porder)
end
return entry
end
local function read_meta(meta)
if meta["glossary-page"] ~= nil then
glossary_page = pandoc.utils.stringify(meta["glossary-page"])
end
if meta.glossary ~= nil then
for k, v in pairs(meta.glossary) do
local entry = parse_meta_entry(v)
if #entry.term == 0 then
entry.term = pandoc.Inlines({ pandoc.Str(k) })
end
glossary[k] = entry
keys[#keys + 1] = k
end
end
if quarto ~= nil and quarto.doc ~= nil and fmt_is("html") then
quarto.doc.add_html_dependency({
name = "calc-glossary",
version = "2.0.0",
stylesheets = { "glossary.css" },
scripts = { "glossary.js" },
})
end
return meta
end
------------------------------------------------------------------ pass 2: inline defs
local pending_props = {}
-- standalone property: ::: {.gls-prop key=div.linearity} (or key=linearity of=div)
local function register_standalone_prop(el)
local key = el.attributes.key
local base, prop
if el.attributes.of ~= nil then
base, prop = el.attributes.of, key
elseif key ~= nil then
base, prop = key:match("^([^%.]+)%.(.+)$")
end
if base == nil then return false end -- plain key: nested style, parent scans it
pending_props[#pending_props + 1] = {
base = base,
prop = prop,
sym = math_of_attr(el.attributes.symbol),
term = el.attributes.term ~= nil
and pandoc.Inlines({ pandoc.Str(el.attributes.term) })
or pandoc.Inlines({ pandoc.Str(prop) }),
def = el.content:clone(),
}
return true
end
-- attach standalone props once every .gls-def has been registered
local function attach_pending(doc)
for _, p in ipairs(pending_props) do
local e = glossary[p.base]
if e == nil then
io.stderr:write("[glossary] property " .. p.base .. "." .. p.prop
.. " has no matching .gls-def for '" .. p.base .. "'\n")
e = { sym = nil, term = pandoc.Inlines({ pandoc.Str(p.base) }),
def = pandoc.Blocks({}), inline = true, props = {}, porder = {} }
glossary[p.base] = e
keys[#keys + 1] = p.base
end
if e.props[p.prop] ~= nil then
io.stderr:write("[glossary] duplicate property: " .. p.base .. "." .. p.prop .. "\n")
else
e.props[p.prop] = { sym = p.sym, term = p.term, def = p.def,
standalone = true }
e.porder[#e.porder + 1] = p.prop
end
end
return doc
end
local function register_inline(el)
if el.classes:includes("gls-prop") then
register_standalone_prop(el)
return nil
end
if not el.classes:includes("gls-def") then return nil end
local key = el.attributes.key
if key == nil then
io.stderr:write("[glossary] .gls-def without key= attribute\n")
return nil
end
local entry = {
sym = math_of_attr(el.attributes.symbol),
term = el.attributes.term ~= nil
and pandoc.Inlines({ pandoc.Str(el.attributes.term) })
or pandoc.Inlines({ pandoc.Str(key) }),
def = pandoc.Blocks({}),
inline = true,
props = {},
porder = {},
}
for _, b in ipairs(el.content) do
if b.t == "Div" and b.classes:includes("gls-prop") then
local pk = b.attributes.key
if pk == nil then
io.stderr:write("[glossary] .gls-prop without key= (in " .. key .. ")\n")
else
entry.props[pk] = {
sym = math_of_attr(b.attributes.symbol),
term = b.attributes.term ~= nil
and pandoc.Inlines({ pandoc.Str(b.attributes.term) })
or pandoc.Inlines({ pandoc.Str(pk) }),
def = b.content:clone(),
}
entry.porder[#entry.porder + 1] = pk
end
else
entry.def:insert(b)
end
end
if glossary[key] ~= nil then
io.stderr:write("[glossary] duplicate key: " .. key .. "\n")
else
keys[#keys + 1] = key
end
glossary[key] = entry
return nil -- registration only; pass 3 transforms
end
------------------------------------------------------------------ pass 3: usages & rendering
-- rewrite \gls{...} occurrences inside a TeX/math string
local function replace_gls(txt, target)
return (txt:gsub("\\gls(%b{})", function(braced)
local key = braced:sub(2, -2)
local r = resolve(key)
if r == nil then
warn_key(key)
return "\\text{??" .. key .. "??}"
end
local disp = math_display(r)
if target == "latex" then
return "\\hyperlink{" .. r.id .. "}{" .. disp .. "}"
else
return "\\href{" .. r.href .. "}{\\class{gls-sym gls-key-" .. key .. "}{" .. disp .. "}}"
end
end))
end
local function Math(el)
if not el.text:find("\\gls") then return nil end
el.text = replace_gls(el.text, fmt_is("latex") and "latex" or "html")
return el
end
local function RawBlock(el)
if el.format ~= "tex" and el.format ~= "latex" then return nil end
if not el.text:find("\\gls") then return nil end
if fmt_is("latex") then
return pandoc.RawBlock(el.format, replace_gls(el.text, "latex"))
end
return nil
end
local function usage(key, display)
local r = resolve(key)
if r == nil then
warn_key(key)
return nil
end
local out = pandoc.Inlines({})
if fmt_is("latex") then
out:insert(pandoc.RawInline("latex", "\\hyperlink{" .. r.id .. "}{"))
out:extend(display)
out:insert(pandoc.RawInline("latex", "}"))
elseif fmt_is("html") then
out:insert(pandoc.RawInline("html",
'<a class="gls-sym" data-gls-key="' .. key .. '" href="' .. r.href .. '">'))
out:extend(display)
out:insert(pandoc.RawInline("html", "</a>"))
else
return display
end
return out
end
local function Span(el)
-- pandoc's LaTeX reader turns \gls{key} into this span (glossaries pkg)
local acr = el.attributes["acronym-label"]
if acr ~= nil then
local r = resolve(acr)
if r == nil then
warn_key(acr)
return nil
end
return usage(acr, pandoc.Inlines({ pandoc.Math("InlineMath", math_display(r)) }))
end
if not el.classes:includes("gls") then return nil end
local key = el.attributes.key or pandoc.utils.stringify(el.content)
local display
if el.attributes.key ~= nil then
display = el.content
else
local r = resolve(key)
display = r and r.term:clone() or el.content
end
return usage(key, display)
end
local function RawInline(el)
if el.format ~= "tex" and el.format ~= "latex" then return nil end
local key = el.text:match("^\\gls%s*{(.-)}$")
if key == nil then return nil end
local r = resolve(key)
if r == nil then return nil end
return usage(key, pandoc.Inlines({ pandoc.Math("InlineMath", math_display(r)) }))
end
-- head line "sym — **term**" (+ latex hypertarget when with_anchor)
local function head_line(id, sym, term, with_anchor)
local head = pandoc.Inlines({})
if with_anchor and fmt_is("latex") then
head:insert(pandoc.RawInline("latex", "\\hypertarget{" .. id .. "}{}"))
end
if sym ~= nil then
head:insert(pandoc.Math("InlineMath", sym))
head:insert(pandoc.Str("\u{2002}\u{2002}"))
end
head:insert(pandoc.Strong(term:clone()))
return pandoc.Para(head)
end
local function prop_div(base, pk, p, with_anchor)
local id = anchor(base .. "." .. pk)
local blocks = pandoc.Blocks({ head_line(id, p.sym, p.term, with_anchor) })
blocks:extend(p.def:clone())
return pandoc.Div(blocks,
pandoc.Attr(with_anchor and id or "", { "gls-entry", "gls-prop-entry" }))
end
-- transform an in-place .gls-def into its rendered definition card
local function render_inline_def(el)
local key = el.attributes.key
if key == nil then return nil end
local e = glossary[key]
if e == nil or not e.inline then return nil end
local id = anchor(key)
local blocks = pandoc.Blocks({ head_line(id, e.sym, e.term, true) })
blocks:extend(e.def:clone())
for _, pk in ipairs(e.porder) do
if not e.props[pk].standalone then -- standalone props render at their own spot
blocks:insert(prop_div(key, pk, e.props[pk], true))
end
end
return pandoc.Div(blocks, pandoc.Attr(id, { "gls-entry", "gls-inline" }))
end
-- ::: {.glossary} ::: -> rendered list
local function render_list(el)
table.sort(keys)
local blocks = pandoc.Blocks({})
for _, key in ipairs(keys) do
local e = glossary[key]
local id = anchor(key)
-- inline-defined entries keep their canonical anchor in the flow;
-- the list clone links back instead of re-anchoring
local with_anchor = not e.inline
local entry = pandoc.Blocks({ head_line(id, e.sym, e.term, with_anchor) })
entry:extend(e.def:clone())
if e.inline then
for _, pk in ipairs(e.porder) do
local p = e.props[pk]
local line = pandoc.Inlines({})
local pid = anchor(key .. "." .. pk)
if fmt_is("latex") then
line:insert(pandoc.RawInline("latex", "\\hyperlink{" .. pid .. "}{"))
elseif fmt_is("html") then
line:insert(pandoc.RawInline("html",
'<a class="gls-back" href="#' .. pid .. '">'))
end
if p.sym ~= nil then
line:insert(pandoc.Math("InlineMath", p.sym))
line:insert(pandoc.Str("\u{2002}"))
end
line:extend(p.term:clone())
if fmt_is("latex") then
line:insert(pandoc.RawInline("latex", "}"))
elseif fmt_is("html") then
line:insert(pandoc.RawInline("html", "</a>"))
end
entry:insert(pandoc.Div(pandoc.Blocks({ pandoc.Plain(line) }),
pandoc.Attr("", { "gls-prop-line" })))
end
local back = pandoc.Inlines({})
if fmt_is("latex") then
back:insert(pandoc.RawInline("latex", "\\hyperlink{" .. id .. "}{"))
back:insert(pandoc.Str("→ definition in context"))
back:insert(pandoc.RawInline("latex", "}"))
else
back:insert(pandoc.RawInline("html",
'<a class="gls-back" href="#' .. id .. '">'))
back:insert(pandoc.Str("→ definition in context"))
back:insert(pandoc.RawInline("html", "</a>"))
end
entry:insert(pandoc.Para(back))
else
for _, pk in ipairs(e.porder) do
entry:insert(prop_div(key, pk, e.props[pk], true))
end
end
blocks:insert(pandoc.Div(entry,
pandoc.Attr(with_anchor and id or "", { "gls-entry" })))
end
return pandoc.Div(blocks, pandoc.Attr(el.identifier, { "glossary" }))
end
-- transform a standalone .gls-prop into its rendered card
local function render_standalone_prop(el)
local key = el.attributes.key
local base, prop
if el.attributes.of ~= nil then
base, prop = el.attributes.of, el.attributes.key
elseif key ~= nil then
base, prop = key:match("^([^%.]+)%.(.+)$")
end
if base == nil then return nil end -- nested style: parent handles it
local e = glossary[base]
local p = e and e.props[prop]
if p == nil then return nil end
local d = prop_div(base, prop, p, true)
d.classes:insert("gls-standalone")
return d
end
------------------------------------------------------------------ tooltip data
local function js_escape(s)
s = s:gsub("\\", "\\\\"):gsub('"', '\\"')
s = s:gsub("\n", "\\n"):gsub("\r", ""):gsub("<%/", "<\\/")
return s
end
local function blocks_to_html(blocks)
if blocks == nil or #blocks == 0 then return "" end
local ok, html = pcall(pandoc.write, pandoc.Pandoc(blocks), "html")
if not ok then return "" end
return html
end
local function head_html(sym, term)
local ins = pandoc.Inlines({})
if sym ~= nil then
ins:insert(pandoc.Math("InlineMath", sym))
ins:insert(pandoc.Str("\u{2002}\u{2014}\u{2002}"))
end
ins:extend(term:clone())
return blocks_to_html(pandoc.Blocks({ pandoc.Para(ins) }))
end
-- make every entry's definition available to the tooltip on any page
local function inject_data(doc)
if not fmt_is("html") then return doc end
if quarto == nil or quarto.doc == nil then return doc end
local parts = {}
for key, e in pairs(glossary) do
parts[#parts + 1] = '"' .. js_escape(key) .. '":"'
.. js_escape(head_html(e.sym, e.term) .. blocks_to_html(e.def)) .. '"'
for pk, p in pairs(e.props) do
parts[#parts + 1] = '"' .. js_escape(key .. "." .. pk) .. '":"'
.. js_escape(head_html(p.sym, p.term) .. blocks_to_html(p.def)) .. '"'
end
end
if #parts == 0 then return doc end
quarto.doc.include_text("after-body",
"<script>window.__glsData = {" .. table.concat(parts, ",") .. "};</script>")
return doc
end
local function Div(el)
if el.classes:includes("gls-def") then
return render_inline_def(el)
elseif el.classes:includes("gls-prop") then
return render_standalone_prop(el)
elseif el.classes:includes("glossary") then
return render_list(el)
end
return nil
end
return {
{ Meta = read_meta },
{ Div = register_inline, Pandoc = attach_pending },
{
Math = Math,
RawBlock = RawBlock,
RawInline = RawInline,
Span = Span,
Div = Div,
Pandoc = inject_data,
},
}
+45
View File
@@ -0,0 +1,45 @@
/* latex-tables — HTML rendering of LaTeX tabular environments */
.ltx-table {
border-collapse: collapse;
margin: 1rem auto;
max-width: 100%;
overflow-x: auto;
display: table;
}
.ltx-table th,
.ltx-table td {
padding: 0.35em 0.7em;
vertical-align: top;
line-height: 1.4;
}
.ltx-table th {
font-weight: 600;
}
/* alignment */
.ltx-l { text-align: left; }
.ltx-c { text-align: center; }
.ltx-r { text-align: right; }
/* vertical rules (from | in the column spec) */
.ltx-bl { border-left: 1px solid currentColor; }
.ltx-br { border-right: 1px solid currentColor; }
/* horizontal rules: t = above the row, b = below.
1 = \hline / \midrule / \cline, 2 = booktabs \toprule / \bottomrule,
d = doubled rule (\hline\hline) */
.ltx-t1 { border-top: 1px solid currentColor; }
.ltx-b1 { border-bottom: 1px solid currentColor; }
.ltx-t2 { border-top: 1.6px solid currentColor; }
.ltx-b2 { border-bottom: 1.6px solid currentColor; }
.ltx-td { border-top: 3px double currentColor; }
.ltx-bd { border-bottom: 3px double currentColor; }
/* keep wide tables usable on narrow screens */
@media (max-width: 700px) {
.ltx-table { font-size: 0.9em; }
.ltx-table th, .ltx-table td { padding: 0.25em 0.45em; }
}
+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 },
}