From 71785583309112a2174e6e0a47037d2b726f9468 Mon Sep 17 00:00:00 2001 From: Romain De Groote Date: Mon, 14 Sep 2026 07:56:24 +0200 Subject: [PATCH] init commit (base extensions: notes) --- .gitignore | 4 + _quarto.yml | 54 ++++ glossary.yml | 0 notes/_extension.yml | 10 + notes/calc-notes.css | 90 +++++++ notes/calc-notes.js | 15 ++ notes/calc-notes.lua | 327 ++++++++++++++++++++++++ notes/exo.css | 57 +++++ notes/exo.js | 33 +++ notes/exo.lua | 237 +++++++++++++++++ notes/glossary.css | 77 ++++++ notes/glossary.js | 68 +++++ notes/glossary.lua | 550 ++++++++++++++++++++++++++++++++++++++++ notes/latex-tables.css | 45 ++++ notes/latex-tables.lua | 563 +++++++++++++++++++++++++++++++++++++++++ ref.bib | 0 16 files changed, 2130 insertions(+) create mode 100644 .gitignore create mode 100644 _quarto.yml create mode 100644 glossary.yml create mode 100644 notes/_extension.yml create mode 100644 notes/calc-notes.css create mode 100644 notes/calc-notes.js create mode 100644 notes/calc-notes.lua create mode 100644 notes/exo.css create mode 100644 notes/exo.js create mode 100644 notes/exo.lua create mode 100644 notes/glossary.css create mode 100644 notes/glossary.js create mode 100644 notes/glossary.lua create mode 100644 notes/latex-tables.css create mode 100644 notes/latex-tables.lua create mode 100644 ref.bib 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