diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..cf41833
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+/.quarto/
+**/*.quarto_ipynb
+
+/.luarc.json
diff --git a/_quarto.yml b/_quarto.yml
new file mode 100644
index 0000000..3b21e90
--- /dev/null
+++ b/_quarto.yml
@@ -0,0 +1,54 @@
+project:
+ type: book
+
+metadata-files:
+ - glossary.yml
+
+book:
+ title: "T"
+ subtitle: ""
+ author:
+ - name: ""
+ affiliation: ""
+ email: ""
+ date: last-modified
+ abstract: >
+ ""
+ chapters:
+ -
+
+website:
+ sidebar:
+ style: docked
+ search: true
+
+format:
+ html:
+ include-in-header:
+ text: |
+
+ pdf:
+ keep-tex: false
+ include-in-header:
+ text: |
+ \usepackage{siunitx}
+ \AtBeginDocument{\renewcommand{\vec}[1]{\symbf{#1}}}
+
+lang: en
+toc: true
+number-sections: true
+bibliography: ref.bib
+filters:
+ - notes
\ No newline at end of file
diff --git a/glossary.yml b/glossary.yml
new file mode 100644
index 0000000..e69de29
diff --git a/notes/_extension.yml b/notes/_extension.yml
new file mode 100644
index 0000000..3c145ea
--- /dev/null
+++ b/notes/_extension.yml
@@ -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
diff --git a/notes/calc-notes.css b/notes/calc-notes.css
new file mode 100644
index 0000000..c794dda
--- /dev/null
+++ b/notes/calc-notes.css
@@ -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; }
+}
diff --git a/notes/calc-notes.js b/notes/calc-notes.js
new file mode 100644
index 0000000..c827e1e
--- /dev/null
+++ b/notes/calc-notes.js
@@ -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;
+ });
+ });
+})();
diff --git a/notes/calc-notes.lua b/notes/calc-notes.lua
new file mode 100644
index 0000000..4939c29
--- /dev/null
+++ b/notes/calc-notes.lua
@@ -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",
+ 'note
'))
+ 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", "
"))
+ 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", ''),
+ pandoc.Math("InlineMath", "\\displaystyle {} " .. flat),
+ pandoc.RawInline("html", ""),
+ },
+ 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",
+ "")
+ 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
diff --git a/notes/exo.css b/notes/exo.css
new file mode 100644
index 0000000..41ff6d0
--- /dev/null
+++ b/notes/exo.css
@@ -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; }
+}
diff --git a/notes/exo.js b/notes/exo.js
new file mode 100644
index 0000000..f64355f
--- /dev/null
+++ b/notes/exo.js
@@ -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;
+ });
+ });
+})();
diff --git a/notes/exo.lua b/notes/exo.lua
new file mode 100644
index 0000000..f09e4b5
--- /dev/null
+++ b/notes/exo.lua
@@ -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",
+ ''
+ .. labels.hint .. " " .. i .. '
'))
+ blocks:extend(h)
+ blocks:insert(pandoc.RawBlock("html", "
"))
+ end
+ if sol ~= nil then
+ blocks:insert(pandoc.RawBlock("html",
+ '' .. labels.solution
+ .. '
'))
+ blocks:extend(sol)
+ blocks:insert(pandoc.RawBlock("html", "
"))
+ 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 },
+}
diff --git a/notes/glossary.css b/notes/glossary.css
new file mode 100644
index 0000000..5b84fe5
--- /dev/null
+++ b/notes/glossary.css
@@ -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;
+}
diff --git a/notes/glossary.js b/notes/glossary.js
new file mode 100644
index 0000000..068722b
--- /dev/null
+++ b/notes/glossary.js
@@ -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 });
+})();
diff --git a/notes/glossary.lua b/notes/glossary.lua
new file mode 100644
index 0000000..b595c47
--- /dev/null
+++ b/notes/glossary.lua
@@ -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",
+ ''))
+ out:extend(display)
+ out:insert(pandoc.RawInline("html", ""))
+ 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",
+ ''))
+ 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", ""))
+ 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",
+ ''))
+ back:insert(pandoc.Str("→ definition in context"))
+ back:insert(pandoc.RawInline("html", ""))
+ 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",
+ "")
+ 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,
+ },
+}
diff --git a/notes/latex-tables.css b/notes/latex-tables.css
new file mode 100644
index 0000000..60cc1e3
--- /dev/null
+++ b/notes/latex-tables.css
@@ -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; }
+}
diff --git a/notes/latex-tables.lua b/notes/latex-tables.lua
new file mode 100644
index 0000000..4a65da9
--- /dev/null
+++ b/notes/latex-tables.lua
@@ -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
; cell contents are parsed as LaTeX, so
+-- $math$, \textbf{...} etc. work.
+--
+-- Header rows: everything above the first rule that follows row 1 becomes
+-- . 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 },
+}
diff --git a/ref.bib b/ref.bib
new file mode 100644
index 0000000..e69de29