init commit (base extensions: notes)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
/.quarto/
|
||||||
|
**/*.quarto_ipynb
|
||||||
|
|
||||||
|
/.luarc.json
|
||||||
+54
@@ -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: |
|
||||||
|
<script>
|
||||||
|
window.MathJax = {
|
||||||
|
tex: {
|
||||||
|
macros: {
|
||||||
|
vec: ["\\boldsymbol{#1}", 1]
|
||||||
|
si: ["\\,\\mathrm{#1}", 1],
|
||||||
|
SI: ["#1\\,\\mathrm{#2}", 2],
|
||||||
|
num: ["#1", 1],
|
||||||
|
ang: ["#1^{\\circ}", 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
-- calc-notes.lua
|
||||||
|
-- "calc" / "calc*" derivation environments for Quarto (HTML + PDF).
|
||||||
|
--
|
||||||
|
-- \begin{calc}
|
||||||
|
-- f(x) &= (x+1)^2 \note{expand the square}\\
|
||||||
|
-- &= x^2 + 2x + 1 \mnote{$(a+b)^2 = a^2+2ab+b^2$}
|
||||||
|
-- \end{calc}
|
||||||
|
--
|
||||||
|
-- \note{...} -> inline note next to the line (foldable in HTML, plain colored text in PDF)
|
||||||
|
-- \mnote{...} -> math note: content is raw math, no $...$ needed; may contain
|
||||||
|
-- aligned lines like "f &= ma \\\\ &= m\\dot{v}"
|
||||||
|
-- calc* -> the last line is boxed to highlight the final result
|
||||||
|
--
|
||||||
|
-- Rename the environment here if you want something other than "calc":
|
||||||
|
local ENV = "calc"
|
||||||
|
local DEFAULT_COLOR = "#B45309" -- overridden by `notecolor:` in the document metadata
|
||||||
|
|
||||||
|
local notecolor = DEFAULT_COLOR
|
||||||
|
|
||||||
|
------------------------------------------------------------------ utils
|
||||||
|
|
||||||
|
local function trim(s)
|
||||||
|
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Works both under Quarto and plain pandoc.
|
||||||
|
local function fmt_is(name)
|
||||||
|
if quarto and quarto.doc and quarto.doc.is_format then
|
||||||
|
return quarto.doc.is_format(name)
|
||||||
|
end
|
||||||
|
return FORMAT ~= nil and FORMAT:match(name) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Split `s` on every occurrence of the literal `sep` that sits at brace
|
||||||
|
-- depth 0 AND outside any nested environment, so neither "\\" inside
|
||||||
|
-- \text{...} nor the row/column separators of a nested \begin{pmatrix},
|
||||||
|
-- \begin{cases}, \begin{array}... ever split a calc line.
|
||||||
|
local function split_top(s, sep)
|
||||||
|
local parts, depth, env, i, start = {}, 0, 0, 1, 1
|
||||||
|
local n, k = #s, #sep
|
||||||
|
while i <= n do
|
||||||
|
if depth == 0 and env == 0 and s:sub(i, i + k - 1) == sep then
|
||||||
|
parts[#parts + 1] = s:sub(start, i - 1)
|
||||||
|
i = i + k
|
||||||
|
start = i
|
||||||
|
else
|
||||||
|
local c = s:sub(i, i)
|
||||||
|
if c == "\\" then
|
||||||
|
if s:find("^\\begin%s*{", i) then
|
||||||
|
env = env + 1
|
||||||
|
i = i + 6
|
||||||
|
elseif s:find("^\\end%s*{", i) then
|
||||||
|
env = math.max(0, env - 1)
|
||||||
|
i = i + 4
|
||||||
|
else
|
||||||
|
i = i + 2 -- skip escaped char / start of a control sequence
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if c == "{" then
|
||||||
|
depth = depth + 1
|
||||||
|
elseif c == "}" then
|
||||||
|
depth = math.max(0, depth - 1)
|
||||||
|
end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
parts[#parts + 1] = s:sub(start)
|
||||||
|
return parts
|
||||||
|
end
|
||||||
|
|
||||||
|
-- s:sub(open_idx) == "{"; returns index of the matching "}".
|
||||||
|
local function find_balanced_close(s, open_idx)
|
||||||
|
local depth, i, n = 0, open_idx, #s
|
||||||
|
while i <= n do
|
||||||
|
local c = s:sub(i, i)
|
||||||
|
if c == "\\" then
|
||||||
|
i = i + 2
|
||||||
|
else
|
||||||
|
if c == "{" then
|
||||||
|
depth = depth + 1
|
||||||
|
elseif c == "}" then
|
||||||
|
depth = depth - 1
|
||||||
|
if depth == 0 then return i end
|
||||||
|
end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Remove every \cmd{...} from `line`; return (cleaned line, {contents...}).
|
||||||
|
local function extract_cmd(line, cmd)
|
||||||
|
local found = {}
|
||||||
|
while true do
|
||||||
|
local st, brace = line:find("\\" .. cmd .. "%s*{")
|
||||||
|
if not st then break end
|
||||||
|
local close = find_balanced_close(line, brace)
|
||||||
|
if not close then break end
|
||||||
|
found[#found + 1] = line:sub(brace + 1, close - 1)
|
||||||
|
line = line:sub(1, st - 1) .. line:sub(close + 1)
|
||||||
|
end
|
||||||
|
return line, found
|
||||||
|
end
|
||||||
|
|
||||||
|
-- \mnote content is raw math; wrap multi-line/aligned content in aligned.
|
||||||
|
-- `opt` is the aligned position option ("[t]" for LaTeX, "" for MathJax).
|
||||||
|
local function wrap_mnote(txt, opt)
|
||||||
|
if #split_top(txt, "\\\\") > 1 or #split_top(txt, "&") > 1 then
|
||||||
|
return "\\begin{aligned}" .. opt .. " " .. txt .. " \\end{aligned}"
|
||||||
|
end
|
||||||
|
return txt
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ parse
|
||||||
|
|
||||||
|
local function parse_body(body)
|
||||||
|
local lines = {}
|
||||||
|
for _, raw in ipairs(split_top(body, "\\\\")) do
|
||||||
|
local l = trim(raw)
|
||||||
|
-- drop an optional spacing argument left over from "\\[1ex]"
|
||||||
|
l = trim(l:gsub("^%[[^%]]*%]", ""))
|
||||||
|
if l ~= "" then
|
||||||
|
local m, notes = extract_cmd(l, "note")
|
||||||
|
local m2, mnotes = extract_cmd(m, "mnote")
|
||||||
|
lines[#lines + 1] = { math = trim(m2), notes = notes, mnotes = mnotes }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return lines
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ HTML
|
||||||
|
|
||||||
|
-- Note contents are parsed as LaTeX so `$...$` inside a note renders as math.
|
||||||
|
local function note_inlines(txt)
|
||||||
|
local ok, doc = pcall(pandoc.read, txt, "latex")
|
||||||
|
if ok and doc and #doc.blocks > 0 then
|
||||||
|
return pandoc.utils.blocks_to_inlines(doc.blocks)
|
||||||
|
end
|
||||||
|
return pandoc.Inlines { pandoc.Str(txt) }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function details_blocks(content, kind)
|
||||||
|
local b = pandoc.Blocks({})
|
||||||
|
b:insert(pandoc.RawBlock("html",
|
||||||
|
'<details class="calc-note calc-note-' .. kind .. '"><summary>note</summary><div class="calc-note-body">'))
|
||||||
|
if kind == "math" then
|
||||||
|
-- \mnote content is raw math — no $...$ needed
|
||||||
|
b:insert(pandoc.Plain {
|
||||||
|
pandoc.Math("InlineMath", wrap_mnote(content, ""))
|
||||||
|
})
|
||||||
|
else
|
||||||
|
b:insert(pandoc.Plain(note_inlines(content)))
|
||||||
|
end
|
||||||
|
b:insert(pandoc.RawBlock("html", "</div></details>"))
|
||||||
|
return b
|
||||||
|
end
|
||||||
|
|
||||||
|
local function math_div(cls, tex)
|
||||||
|
return pandoc.Div(
|
||||||
|
pandoc.Plain { pandoc.Math("InlineMath", "\\displaystyle " .. tex) },
|
||||||
|
pandoc.Attr("", { cls }))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function html_env(lines, star)
|
||||||
|
local rows = pandoc.Blocks({})
|
||||||
|
for idx, L in ipairs(lines) do
|
||||||
|
local final = star and idx == #lines
|
||||||
|
local cells = pandoc.Blocks({})
|
||||||
|
|
||||||
|
if final then
|
||||||
|
-- boxed final result: render the whole line (alignment "&" stripped)
|
||||||
|
local flat = trim(table.concat(split_top(L.math, "&"), " "))
|
||||||
|
cells:insert(pandoc.Div(
|
||||||
|
pandoc.Plain {
|
||||||
|
pandoc.RawInline("html", '<span class="calc-box">'),
|
||||||
|
pandoc.Math("InlineMath", "\\displaystyle {} " .. flat),
|
||||||
|
pandoc.RawInline("html", "</span>"),
|
||||||
|
},
|
||||||
|
pandoc.Attr("", { "calc-full" })))
|
||||||
|
else
|
||||||
|
local parts = split_top(L.math, "&")
|
||||||
|
if #parts == 1 then
|
||||||
|
cells:insert(math_div("calc-full", parts[1]))
|
||||||
|
else
|
||||||
|
local lhs = trim(parts[1])
|
||||||
|
local rhs = trim(table.concat(parts, " ", 2))
|
||||||
|
if lhs ~= "" then
|
||||||
|
cells:insert(math_div("calc-lhs", lhs))
|
||||||
|
end
|
||||||
|
-- leading "{}" keeps correct spacing for a line starting with "= ..."
|
||||||
|
cells:insert(math_div("calc-rhs", "{} " .. rhs))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local nb = pandoc.Blocks({})
|
||||||
|
for _, t in ipairs(L.notes) do nb:extend(details_blocks(t, "inline")) end
|
||||||
|
for _, t in ipairs(L.mnotes) do nb:extend(details_blocks(t, "math")) end
|
||||||
|
if #nb > 0 then
|
||||||
|
cells:insert(pandoc.Div(nb, pandoc.Attr("", { "calc-notes" })))
|
||||||
|
end
|
||||||
|
|
||||||
|
local rowcls = { "calc-row" }
|
||||||
|
if final then rowcls[#rowcls + 1] = "calc-final" end
|
||||||
|
rows:insert(pandoc.Div(cells, pandoc.Attr("", rowcls)))
|
||||||
|
end
|
||||||
|
local cls = star and { "calc", "calc-star" } or { "calc" }
|
||||||
|
return pandoc.Blocks { pandoc.Div(rows, pandoc.Attr("", cls)) }
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ LaTeX
|
||||||
|
|
||||||
|
local function latex_env(lines, star)
|
||||||
|
local out = {}
|
||||||
|
for idx, L in ipairs(lines) do
|
||||||
|
local seg = L.math
|
||||||
|
if star and idx == #lines then
|
||||||
|
if #split_top(seg, "&") > 1 then
|
||||||
|
seg = "\\Aboxed{" .. seg .. "}" -- boxes across the alignment point (mathtools)
|
||||||
|
else
|
||||||
|
seg = "\\boxed{" .. seg .. "}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, t in ipairs(L.notes) do
|
||||||
|
seg = seg .. " \\quad {\\color{notecolor}\\text{\\small " .. t .. "}}"
|
||||||
|
end
|
||||||
|
for _, t in ipairs(L.mnotes) do
|
||||||
|
-- math note: content is math already; \text{\small$..$} scales it down
|
||||||
|
seg = seg .. " \\quad {\\color{notecolor}\\text{\\small$"
|
||||||
|
.. wrap_mnote(t, "[t]") .. "$}}"
|
||||||
|
end
|
||||||
|
out[#out + 1] = " " .. seg
|
||||||
|
end
|
||||||
|
return pandoc.Blocks {
|
||||||
|
pandoc.RawBlock("latex",
|
||||||
|
"\\begin{align*}\n" .. table.concat(out, " \\\\\n") .. "\n\\end{align*}")
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ filter
|
||||||
|
|
||||||
|
local function transform(txt)
|
||||||
|
local star, body = txt:match(
|
||||||
|
"^%s*\\begin{" .. ENV .. "(%*?)}(.-)\\end{" .. ENV .. "%*?}%s*$")
|
||||||
|
if body == nil then return nil end
|
||||||
|
local lines = parse_body(body)
|
||||||
|
if #lines == 0 then return nil end
|
||||||
|
local isstar = (star == "*")
|
||||||
|
if fmt_is("latex") then
|
||||||
|
return latex_env(lines, isstar)
|
||||||
|
elseif fmt_is("html") then
|
||||||
|
return html_env(lines, isstar)
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function RawBlock(el)
|
||||||
|
if el.format ~= "tex" and el.format ~= "latex" then return nil end
|
||||||
|
return transform(el.text)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- also catch the env when it's wrapped in $$ ... $$
|
||||||
|
-- The math may share its paragraph with other text (a line touching the
|
||||||
|
-- fence, a trailing character, an inline "$$...$$" in a sentence). Split
|
||||||
|
-- the paragraph: text before/after stays as text, the env becomes blocks.
|
||||||
|
local function is_ws(it)
|
||||||
|
return it.t == "Space" or it.t == "SoftBreak" or it.t == "LineBreak"
|
||||||
|
or (it.t == "Str" and it.text:match("^%s*$") ~= nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function flush(cur, out, ctor)
|
||||||
|
local only_ws = true
|
||||||
|
for _, it in ipairs(cur) do
|
||||||
|
if not is_ws(it) then only_ws = false; break end
|
||||||
|
end
|
||||||
|
if not only_ws then out:insert(ctor(cur)) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function block_with_math(el, ctor)
|
||||||
|
local out = pandoc.Blocks({})
|
||||||
|
local cur = pandoc.Inlines({})
|
||||||
|
local found = false
|
||||||
|
for _, it in ipairs(el.content) do
|
||||||
|
local res = nil
|
||||||
|
if it.t == "Math" and it.mathtype == "DisplayMath" then
|
||||||
|
res = transform(it.text)
|
||||||
|
end
|
||||||
|
if res ~= nil then
|
||||||
|
found = true
|
||||||
|
flush(cur, out, ctor)
|
||||||
|
cur = pandoc.Inlines({})
|
||||||
|
out:extend(res)
|
||||||
|
else
|
||||||
|
cur:insert(it)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not found then return nil end
|
||||||
|
flush(cur, out, ctor)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function Para(el) return block_with_math(el, pandoc.Para) end
|
||||||
|
function Plain(el) return block_with_math(el, pandoc.Plain) end
|
||||||
|
|
||||||
|
function Meta(m)
|
||||||
|
if m.notecolor then
|
||||||
|
notecolor = pandoc.utils.stringify(m.notecolor)
|
||||||
|
end
|
||||||
|
if quarto == nil or quarto.doc == nil then return end -- plain pandoc: skip injection
|
||||||
|
if fmt_is("html") then
|
||||||
|
quarto.doc.add_html_dependency({
|
||||||
|
name = "calc-notes",
|
||||||
|
version = "1.0.0",
|
||||||
|
stylesheets = { "calc-notes.css" },
|
||||||
|
scripts = { "calc-notes.js" },
|
||||||
|
})
|
||||||
|
quarto.doc.include_text("in-header",
|
||||||
|
"<style>:root { --calc-notecolor: " .. notecolor .. "; }</style>")
|
||||||
|
elseif fmt_is("latex") then
|
||||||
|
quarto.doc.use_latex_package("mathtools") -- \Aboxed
|
||||||
|
quarto.doc.use_latex_package("xcolor")
|
||||||
|
local hex = notecolor:gsub("^#", "")
|
||||||
|
quarto.doc.include_text("in-header", table.concat({
|
||||||
|
"\\definecolor{notecolor}{HTML}{" .. hex .. "}",
|
||||||
|
}, "\n"))
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// exo — hints unlock one by one; everything unfolds for printing
|
||||||
|
(function () {
|
||||||
|
// block opening a locked hint
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
var s = e.target.closest ? e.target.closest("details.exo-hint.exo-locked > summary") : null;
|
||||||
|
if (s) e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// opening hint n unlocks hint n+1 (toggle doesn't bubble: use capture)
|
||||||
|
document.addEventListener("toggle", function (e) {
|
||||||
|
var d = e.target;
|
||||||
|
if (!d.classList || !d.classList.contains("exo-hint") || !d.open) return;
|
||||||
|
var next = d.nextElementSibling;
|
||||||
|
while (next && !(next.tagName === "DETAILS" && next.classList.contains("exo-hint"))) {
|
||||||
|
next = next.nextElementSibling;
|
||||||
|
}
|
||||||
|
if (next) next.classList.remove("exo-locked");
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// print: open all hints/solutions, restore afterwards
|
||||||
|
window.addEventListener("beforeprint", function () {
|
||||||
|
document.querySelectorAll(".exo details").forEach(function (d) {
|
||||||
|
d.dataset.exoWasOpen = d.open ? "1" : "0";
|
||||||
|
d.open = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
window.addEventListener("afterprint", function () {
|
||||||
|
document.querySelectorAll(".exo details").forEach(function (d) {
|
||||||
|
d.open = d.dataset.exoWasOpen === "1";
|
||||||
|
delete d.dataset.exoWasOpen;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
-- exo.lua — numbered exercise environments for Quarto (HTML + PDF)
|
||||||
|
--
|
||||||
|
-- :::: {.exo title="Divergence practice"}
|
||||||
|
-- Statement, figures, anything...
|
||||||
|
--
|
||||||
|
-- ::: {.hint}
|
||||||
|
-- First hint.
|
||||||
|
-- :::
|
||||||
|
--
|
||||||
|
-- ::: {.hint}
|
||||||
|
-- Second hint.
|
||||||
|
-- :::
|
||||||
|
--
|
||||||
|
-- ::: {.solution}
|
||||||
|
-- Full solution.
|
||||||
|
-- :::
|
||||||
|
-- ::::
|
||||||
|
--
|
||||||
|
-- Numbering is chapter.n where "chapter" counts level-1 headers (#) and n
|
||||||
|
-- resets per chapter (1.1, 1.2, 2.1, ...). Without any level-1 header the
|
||||||
|
-- number is just n.
|
||||||
|
--
|
||||||
|
-- HTML: hints are numbered folds that unlock one by one; the solution is a
|
||||||
|
-- fold, closed by default.
|
||||||
|
-- PDF: the statement stays in place, hints become small highlight boxes,
|
||||||
|
-- solutions are collected into an unnumbered "Solutions" section at
|
||||||
|
-- the end of each chapter, cross-linked both ways with page refs.
|
||||||
|
--
|
||||||
|
-- Labels are configurable via metadata:
|
||||||
|
-- exo:
|
||||||
|
-- label: "Exercice"
|
||||||
|
-- hint: "Indice"
|
||||||
|
-- solution: "Solution"
|
||||||
|
-- solutions: "Solutions"
|
||||||
|
|
||||||
|
local labels = {
|
||||||
|
label = "Exercise",
|
||||||
|
hint = "Hint",
|
||||||
|
solution = "Solution",
|
||||||
|
solutions = "Solutions",
|
||||||
|
}
|
||||||
|
|
||||||
|
local function fmt_is(name)
|
||||||
|
if quarto and quarto.doc and quarto.doc.is_format then
|
||||||
|
return quarto.doc.is_format(name)
|
||||||
|
end
|
||||||
|
return FORMAT ~= nil and FORMAT:match(name) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ meta
|
||||||
|
|
||||||
|
local function read_meta(meta)
|
||||||
|
if meta.exo ~= nil then
|
||||||
|
for k in pairs(labels) do
|
||||||
|
if meta.exo[k] ~= nil then
|
||||||
|
labels[k] = pandoc.utils.stringify(meta.exo[k])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if quarto ~= nil and quarto.doc ~= nil then
|
||||||
|
if fmt_is("html") then
|
||||||
|
quarto.doc.add_html_dependency({
|
||||||
|
name = "calc-exo",
|
||||||
|
version = "1.0.0",
|
||||||
|
stylesheets = { "exo.css" },
|
||||||
|
scripts = { "exo.js" },
|
||||||
|
})
|
||||||
|
elseif fmt_is("latex") then
|
||||||
|
quarto.doc.use_latex_package("tcolorbox")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return meta
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ pieces
|
||||||
|
|
||||||
|
-- Quarto pre-processes ::: {.solution} (and .proof/.remark) into an
|
||||||
|
-- internal custom node before user filters run. Inside an .exo we take
|
||||||
|
-- such a node as the solution and unwrap its scaffold to get the body.
|
||||||
|
local function custom_proof_body(div)
|
||||||
|
for _, sc in ipairs(div.content) do
|
||||||
|
if sc.t == "Div" and sc.attributes.__quarto_custom_scaffold ~= nil
|
||||||
|
and #sc.content > 0 then
|
||||||
|
local inner = sc.content
|
||||||
|
if #inner == 1 and inner[1].t == "Div" and #inner[1].classes == 0 then
|
||||||
|
return inner[1].content
|
||||||
|
end
|
||||||
|
return inner
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return div.content
|
||||||
|
end
|
||||||
|
|
||||||
|
local function split_exo(el)
|
||||||
|
local stmt, hints, sol = pandoc.Blocks({}), {}, nil
|
||||||
|
for _, b in ipairs(el.content) do
|
||||||
|
if b.t == "Div" and b.classes:includes("hint") then
|
||||||
|
hints[#hints + 1] = b.content
|
||||||
|
elseif b.t == "Div" and (b.classes:includes("solution")
|
||||||
|
or b.classes:includes("sol")) then
|
||||||
|
sol = b.content
|
||||||
|
elseif b.t == "Div" and b.attributes.__quarto_custom_type == "Proof" then
|
||||||
|
sol = custom_proof_body(b)
|
||||||
|
else
|
||||||
|
stmt:insert(b)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return stmt, hints, sol
|
||||||
|
end
|
||||||
|
|
||||||
|
local function head_inlines(num, title)
|
||||||
|
local ins = pandoc.Inlines({ pandoc.Str(labels.label .. " " .. num) })
|
||||||
|
if title ~= nil then
|
||||||
|
ins:insert(pandoc.Str("\u{2002}—\u{2002}"))
|
||||||
|
ins:insert(pandoc.Emph(pandoc.Inlines({ pandoc.Str(title) })))
|
||||||
|
end
|
||||||
|
return ins
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ html
|
||||||
|
|
||||||
|
local function html_exo(num, title, stmt, hints, sol)
|
||||||
|
local blocks = pandoc.Blocks({})
|
||||||
|
blocks:insert(pandoc.Div(
|
||||||
|
pandoc.Blocks({ pandoc.Para(pandoc.Inlines({
|
||||||
|
pandoc.Strong(head_inlines(num, title)) })) }),
|
||||||
|
pandoc.Attr("", { "exo-head" })))
|
||||||
|
blocks:extend(stmt)
|
||||||
|
for i, h in ipairs(hints) do
|
||||||
|
local locked = (i > 1) and " exo-locked" or ""
|
||||||
|
blocks:insert(pandoc.RawBlock("html",
|
||||||
|
'<details class="exo-hint' .. locked .. '"><summary>'
|
||||||
|
.. labels.hint .. " " .. i .. '</summary><div class="exo-fold-body">'))
|
||||||
|
blocks:extend(h)
|
||||||
|
blocks:insert(pandoc.RawBlock("html", "</div></details>"))
|
||||||
|
end
|
||||||
|
if sol ~= nil then
|
||||||
|
blocks:insert(pandoc.RawBlock("html",
|
||||||
|
'<details class="exo-sol"><summary>' .. labels.solution
|
||||||
|
.. '</summary><div class="exo-fold-body">'))
|
||||||
|
blocks:extend(sol)
|
||||||
|
blocks:insert(pandoc.RawBlock("html", "</div></details>"))
|
||||||
|
end
|
||||||
|
return pandoc.Div(blocks, pandoc.Attr("exo-" .. num, { "exo" }))
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ latex
|
||||||
|
|
||||||
|
local function latex_exo(num, title, stmt, hints, sol)
|
||||||
|
local out = pandoc.Blocks({})
|
||||||
|
local head = pandoc.Inlines({
|
||||||
|
pandoc.RawInline("latex", "\\hypertarget{exo-" .. num .. "}{}"),
|
||||||
|
pandoc.Strong(head_inlines(num, title)),
|
||||||
|
})
|
||||||
|
out:insert(pandoc.Para(head))
|
||||||
|
out:extend(stmt)
|
||||||
|
for i, h in ipairs(hints) do
|
||||||
|
out:insert(pandoc.RawBlock("latex",
|
||||||
|
"\\begin{tcolorbox}[colback=notecolor!5!white,colframe=notecolor!40!white,colbacktitle=notecolor!15!white,"
|
||||||
|
.. "coltitle=black,fonttitle=\\small\\bfseries,title={" .. labels.hint
|
||||||
|
.. " " .. i .. "},left=1.5mm,right=1.5mm,top=1mm,bottom=1mm]"))
|
||||||
|
out:extend(h)
|
||||||
|
out:insert(pandoc.RawBlock("latex", "\\end{tcolorbox}"))
|
||||||
|
end
|
||||||
|
local solblocks = nil
|
||||||
|
if sol ~= nil then
|
||||||
|
out:insert(pandoc.Para(pandoc.Inlines({
|
||||||
|
pandoc.RawInline("latex",
|
||||||
|
"{\\small\\itshape\\hyperlink{sol-" .. num .. "}{" .. labels.solution
|
||||||
|
.. " " .. num .. " $\\to$ p.\\,\\pageref{sol:" .. num .. "}}}"),
|
||||||
|
})))
|
||||||
|
solblocks = pandoc.Blocks({ pandoc.Para(pandoc.Inlines({
|
||||||
|
pandoc.RawInline("latex",
|
||||||
|
"\\hypertarget{sol-" .. num .. "}{}\\label{sol:" .. num .. "}"),
|
||||||
|
pandoc.Strong(pandoc.Inlines({ pandoc.Str(labels.solution .. " " .. num) })),
|
||||||
|
pandoc.Str("\u{2002}"),
|
||||||
|
pandoc.RawInline("latex",
|
||||||
|
"{\\small(\\hyperlink{exo-" .. num .. "}{$\\leftarrow$ "
|
||||||
|
.. labels.label .. " " .. num .. "})}"),
|
||||||
|
})) })
|
||||||
|
solblocks:extend(sol)
|
||||||
|
end
|
||||||
|
return out, solblocks
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ walk
|
||||||
|
|
||||||
|
local function walk(doc)
|
||||||
|
local is_latex = fmt_is("latex")
|
||||||
|
local is_html = fmt_is("html")
|
||||||
|
local chapter, cnt = 0, 0
|
||||||
|
local pending = {}
|
||||||
|
local out = pandoc.Blocks({})
|
||||||
|
|
||||||
|
local function flush()
|
||||||
|
if #pending == 0 then return end
|
||||||
|
out:insert(pandoc.Header(2,
|
||||||
|
pandoc.Inlines({ pandoc.Str(labels.solutions) }),
|
||||||
|
pandoc.Attr("", { "unnumbered" })))
|
||||||
|
for _, s in ipairs(pending) do out:extend(s) end
|
||||||
|
pending = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, b in ipairs(doc.blocks) do
|
||||||
|
if b.t == "Header" and b.level == 1 then
|
||||||
|
if is_latex then flush() end
|
||||||
|
if not b.classes:includes("unnumbered") then -- prefaces etc. don't count
|
||||||
|
chapter = chapter + 1
|
||||||
|
cnt = 0
|
||||||
|
end
|
||||||
|
out:insert(b)
|
||||||
|
elseif b.t == "Div" and b.classes:includes("exo") then
|
||||||
|
cnt = cnt + 1
|
||||||
|
local num = chapter > 0 and (chapter .. "." .. cnt) or tostring(cnt)
|
||||||
|
local stmt, hints, sol = split_exo(b)
|
||||||
|
if is_latex then
|
||||||
|
local inplace, solb = latex_exo(num, b.attributes.title, stmt, hints, sol)
|
||||||
|
out:extend(inplace)
|
||||||
|
if solb ~= nil then pending[#pending + 1] = solb end
|
||||||
|
elseif is_html then
|
||||||
|
out:insert(html_exo(num, b.attributes.title, stmt, hints, sol))
|
||||||
|
else
|
||||||
|
out:insert(b)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
out:insert(b)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if is_latex then flush() end
|
||||||
|
doc.blocks = out
|
||||||
|
return doc
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
{ Meta = read_meta },
|
||||||
|
{ Pandoc = walk },
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
})();
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
-- glossary.lua — clickable/hoverable glossary for Quarto (HTML + PDF)
|
||||||
|
--
|
||||||
|
-- Entries can be defined two ways:
|
||||||
|
--
|
||||||
|
-- (1) in metadata (document or _quarto.yml), rendered by the glossary list:
|
||||||
|
--
|
||||||
|
-- glossary:
|
||||||
|
-- laplacian:
|
||||||
|
-- symbol: "$\\nabla^2$"
|
||||||
|
-- term: "Laplacian"
|
||||||
|
-- def: "Divergence of the gradient."
|
||||||
|
-- props: # optional sub-entries
|
||||||
|
-- linearity:
|
||||||
|
-- term: "linearity"
|
||||||
|
-- def: "$\\nabla^2(af+bg) = a\\nabla^2 f + b\\nabla^2 g$"
|
||||||
|
--
|
||||||
|
-- (2) inline, at the natural place in the document flow:
|
||||||
|
--
|
||||||
|
-- :::: {.gls-def key=laplacian symbol="$\nabla^2$" term="Laplacian"}
|
||||||
|
-- Divergence of the gradient: $\nabla^2 f = \nabla\cdot\nabla f$.
|
||||||
|
--
|
||||||
|
-- ::: {.gls-prop key=linearity term="linearity"}
|
||||||
|
-- $\nabla^2(af + bg) = a\,\nabla^2 f + b\,\nabla^2 g$.
|
||||||
|
-- :::
|
||||||
|
-- ::::
|
||||||
|
--
|
||||||
|
-- The div renders in place as the definition card AND registers the
|
||||||
|
-- entry; usages link back to this spot.
|
||||||
|
--
|
||||||
|
-- Usage (works for entries and properties):
|
||||||
|
-- in math: \gls{laplacian} \gls{laplacian.linearity}
|
||||||
|
-- in prose: [laplacian]{.gls} [by linearity]{.gls key=laplacian.linearity}
|
||||||
|
-- the list: ::: {.glossary}\n:::
|
||||||
|
--
|
||||||
|
-- HTML: usages are links with hover popups. PDF: hyperref links.
|
||||||
|
|
||||||
|
local glossary = {} -- key -> entry
|
||||||
|
local keys = {} -- sorted list of top-level keys
|
||||||
|
local glossary_page = nil -- e.g. "glossary.html": where metadata entries live
|
||||||
|
-- entry = { sym, term = Inlines, def = Blocks, inline = bool,
|
||||||
|
-- props = { pkey -> {sym, term = Inlines, def = Blocks} },
|
||||||
|
-- porder = { pkey... } }
|
||||||
|
|
||||||
|
local function fmt_is(name)
|
||||||
|
if quarto and quarto.doc and quarto.doc.is_format then
|
||||||
|
return quarto.doc.is_format(name)
|
||||||
|
end
|
||||||
|
return FORMAT ~= nil and FORMAT:match(name) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ helpers
|
||||||
|
|
||||||
|
-- first Math element's text within a metadata value / inline list
|
||||||
|
local function math_of(v)
|
||||||
|
if v == nil then return nil end
|
||||||
|
local inlines = v
|
||||||
|
if pandoc.utils.type(v) == "Blocks" then
|
||||||
|
inlines = pandoc.utils.blocks_to_inlines(v)
|
||||||
|
end
|
||||||
|
for _, it in ipairs(inlines) do
|
||||||
|
if it.t == "Math" then return it.text end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- math text out of an attribute string like "$\nabla^2$" (or bare "\nabla^2")
|
||||||
|
local function math_of_attr(s)
|
||||||
|
if s == nil then return nil end
|
||||||
|
local inner = s:match("^%s*%$(.-)%$%s*$")
|
||||||
|
return inner or s
|
||||||
|
end
|
||||||
|
|
||||||
|
local function to_inlines(v)
|
||||||
|
if v == nil then return pandoc.Inlines({}) end
|
||||||
|
if pandoc.utils.type(v) == "Blocks" then
|
||||||
|
return pandoc.utils.blocks_to_inlines(v)
|
||||||
|
end
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
|
local function to_blocks(v)
|
||||||
|
if v == nil then return pandoc.Blocks({}) end
|
||||||
|
if pandoc.utils.type(v) == "Blocks" then return v end
|
||||||
|
return pandoc.Blocks({ pandoc.Plain(v) })
|
||||||
|
end
|
||||||
|
|
||||||
|
local function anchor(key) return "gls-" .. key end
|
||||||
|
|
||||||
|
-- resolve "key" or "key.prop"; returns display info + anchor or nil
|
||||||
|
-- Entries defined inline live on this page; entries that come from
|
||||||
|
-- metadata live wherever the ::: {.glossary} list is rendered. In a book
|
||||||
|
-- each chapter is a separate HTML page, so those need a page-qualified
|
||||||
|
-- link (set `glossary-page:` in metadata).
|
||||||
|
local function href_of(base, id)
|
||||||
|
local e = glossary[base]
|
||||||
|
if e ~= nil and e.inline then return "#" .. id end
|
||||||
|
if glossary_page ~= nil and fmt_is("html") then
|
||||||
|
return glossary_page .. "#" .. id
|
||||||
|
end
|
||||||
|
return "#" .. id
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve(full)
|
||||||
|
local base, prop = full:match("^([^%.]+)%.(.+)$")
|
||||||
|
base = base or full
|
||||||
|
local e = glossary[base]
|
||||||
|
if e == nil then return nil end
|
||||||
|
local id = anchor(full)
|
||||||
|
if prop ~= nil then
|
||||||
|
local p = e.props[prop]
|
||||||
|
if p == nil then return nil end
|
||||||
|
return { sym = p.sym, term = p.term, id = id, href = href_of(base, id) }
|
||||||
|
end
|
||||||
|
return { sym = e.sym, term = e.term, id = id, href = href_of(base, id) }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- what \gls{...} displays in math mode
|
||||||
|
local function math_display(r)
|
||||||
|
return r.sym or ("\\text{" .. pandoc.utils.stringify(r.term) .. "}")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function warn_key(key)
|
||||||
|
io.stderr:write("[glossary] unknown key: " .. key
|
||||||
|
.. " (if it is defined with ::: {.gls-def} in another chapter, note that"
|
||||||
|
.. " HTML book chapters render separately - define shared terms in"
|
||||||
|
.. " metadata / glossary.yml instead)\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ pass 1: metadata
|
||||||
|
|
||||||
|
local function parse_meta_entry(v)
|
||||||
|
local entry = {
|
||||||
|
sym = math_of(v.symbol),
|
||||||
|
term = to_inlines(v.term),
|
||||||
|
def = to_blocks(v.def),
|
||||||
|
inline = false,
|
||||||
|
props = {},
|
||||||
|
porder = {},
|
||||||
|
}
|
||||||
|
if v.props ~= nil then
|
||||||
|
for pk, pv in pairs(v.props) do
|
||||||
|
entry.props[pk] = {
|
||||||
|
sym = math_of(pv.symbol),
|
||||||
|
term = #to_inlines(pv.term) > 0 and to_inlines(pv.term)
|
||||||
|
or pandoc.Inlines({ pandoc.Str(pk) }),
|
||||||
|
def = to_blocks(pv.def),
|
||||||
|
}
|
||||||
|
entry.porder[#entry.porder + 1] = pk
|
||||||
|
end
|
||||||
|
table.sort(entry.porder)
|
||||||
|
end
|
||||||
|
return entry
|
||||||
|
end
|
||||||
|
|
||||||
|
local function read_meta(meta)
|
||||||
|
if meta["glossary-page"] ~= nil then
|
||||||
|
glossary_page = pandoc.utils.stringify(meta["glossary-page"])
|
||||||
|
end
|
||||||
|
if meta.glossary ~= nil then
|
||||||
|
for k, v in pairs(meta.glossary) do
|
||||||
|
local entry = parse_meta_entry(v)
|
||||||
|
if #entry.term == 0 then
|
||||||
|
entry.term = pandoc.Inlines({ pandoc.Str(k) })
|
||||||
|
end
|
||||||
|
glossary[k] = entry
|
||||||
|
keys[#keys + 1] = k
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if quarto ~= nil and quarto.doc ~= nil and fmt_is("html") then
|
||||||
|
quarto.doc.add_html_dependency({
|
||||||
|
name = "calc-glossary",
|
||||||
|
version = "2.0.0",
|
||||||
|
stylesheets = { "glossary.css" },
|
||||||
|
scripts = { "glossary.js" },
|
||||||
|
})
|
||||||
|
end
|
||||||
|
return meta
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ pass 2: inline defs
|
||||||
|
|
||||||
|
local pending_props = {}
|
||||||
|
|
||||||
|
-- standalone property: ::: {.gls-prop key=div.linearity} (or key=linearity of=div)
|
||||||
|
local function register_standalone_prop(el)
|
||||||
|
local key = el.attributes.key
|
||||||
|
local base, prop
|
||||||
|
if el.attributes.of ~= nil then
|
||||||
|
base, prop = el.attributes.of, key
|
||||||
|
elseif key ~= nil then
|
||||||
|
base, prop = key:match("^([^%.]+)%.(.+)$")
|
||||||
|
end
|
||||||
|
if base == nil then return false end -- plain key: nested style, parent scans it
|
||||||
|
pending_props[#pending_props + 1] = {
|
||||||
|
base = base,
|
||||||
|
prop = prop,
|
||||||
|
sym = math_of_attr(el.attributes.symbol),
|
||||||
|
term = el.attributes.term ~= nil
|
||||||
|
and pandoc.Inlines({ pandoc.Str(el.attributes.term) })
|
||||||
|
or pandoc.Inlines({ pandoc.Str(prop) }),
|
||||||
|
def = el.content:clone(),
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- attach standalone props once every .gls-def has been registered
|
||||||
|
local function attach_pending(doc)
|
||||||
|
for _, p in ipairs(pending_props) do
|
||||||
|
local e = glossary[p.base]
|
||||||
|
if e == nil then
|
||||||
|
io.stderr:write("[glossary] property " .. p.base .. "." .. p.prop
|
||||||
|
.. " has no matching .gls-def for '" .. p.base .. "'\n")
|
||||||
|
e = { sym = nil, term = pandoc.Inlines({ pandoc.Str(p.base) }),
|
||||||
|
def = pandoc.Blocks({}), inline = true, props = {}, porder = {} }
|
||||||
|
glossary[p.base] = e
|
||||||
|
keys[#keys + 1] = p.base
|
||||||
|
end
|
||||||
|
if e.props[p.prop] ~= nil then
|
||||||
|
io.stderr:write("[glossary] duplicate property: " .. p.base .. "." .. p.prop .. "\n")
|
||||||
|
else
|
||||||
|
e.props[p.prop] = { sym = p.sym, term = p.term, def = p.def,
|
||||||
|
standalone = true }
|
||||||
|
e.porder[#e.porder + 1] = p.prop
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return doc
|
||||||
|
end
|
||||||
|
|
||||||
|
local function register_inline(el)
|
||||||
|
if el.classes:includes("gls-prop") then
|
||||||
|
register_standalone_prop(el)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if not el.classes:includes("gls-def") then return nil end
|
||||||
|
local key = el.attributes.key
|
||||||
|
if key == nil then
|
||||||
|
io.stderr:write("[glossary] .gls-def without key= attribute\n")
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local entry = {
|
||||||
|
sym = math_of_attr(el.attributes.symbol),
|
||||||
|
term = el.attributes.term ~= nil
|
||||||
|
and pandoc.Inlines({ pandoc.Str(el.attributes.term) })
|
||||||
|
or pandoc.Inlines({ pandoc.Str(key) }),
|
||||||
|
def = pandoc.Blocks({}),
|
||||||
|
inline = true,
|
||||||
|
props = {},
|
||||||
|
porder = {},
|
||||||
|
}
|
||||||
|
for _, b in ipairs(el.content) do
|
||||||
|
if b.t == "Div" and b.classes:includes("gls-prop") then
|
||||||
|
local pk = b.attributes.key
|
||||||
|
if pk == nil then
|
||||||
|
io.stderr:write("[glossary] .gls-prop without key= (in " .. key .. ")\n")
|
||||||
|
else
|
||||||
|
entry.props[pk] = {
|
||||||
|
sym = math_of_attr(b.attributes.symbol),
|
||||||
|
term = b.attributes.term ~= nil
|
||||||
|
and pandoc.Inlines({ pandoc.Str(b.attributes.term) })
|
||||||
|
or pandoc.Inlines({ pandoc.Str(pk) }),
|
||||||
|
def = b.content:clone(),
|
||||||
|
}
|
||||||
|
entry.porder[#entry.porder + 1] = pk
|
||||||
|
end
|
||||||
|
else
|
||||||
|
entry.def:insert(b)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if glossary[key] ~= nil then
|
||||||
|
io.stderr:write("[glossary] duplicate key: " .. key .. "\n")
|
||||||
|
else
|
||||||
|
keys[#keys + 1] = key
|
||||||
|
end
|
||||||
|
glossary[key] = entry
|
||||||
|
return nil -- registration only; pass 3 transforms
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ pass 3: usages & rendering
|
||||||
|
|
||||||
|
-- rewrite \gls{...} occurrences inside a TeX/math string
|
||||||
|
local function replace_gls(txt, target)
|
||||||
|
return (txt:gsub("\\gls(%b{})", function(braced)
|
||||||
|
local key = braced:sub(2, -2)
|
||||||
|
local r = resolve(key)
|
||||||
|
if r == nil then
|
||||||
|
warn_key(key)
|
||||||
|
return "\\text{??" .. key .. "??}"
|
||||||
|
end
|
||||||
|
local disp = math_display(r)
|
||||||
|
if target == "latex" then
|
||||||
|
return "\\hyperlink{" .. r.id .. "}{" .. disp .. "}"
|
||||||
|
else
|
||||||
|
return "\\href{" .. r.href .. "}{\\class{gls-sym gls-key-" .. key .. "}{" .. disp .. "}}"
|
||||||
|
end
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function Math(el)
|
||||||
|
if not el.text:find("\\gls") then return nil end
|
||||||
|
el.text = replace_gls(el.text, fmt_is("latex") and "latex" or "html")
|
||||||
|
return el
|
||||||
|
end
|
||||||
|
|
||||||
|
local function RawBlock(el)
|
||||||
|
if el.format ~= "tex" and el.format ~= "latex" then return nil end
|
||||||
|
if not el.text:find("\\gls") then return nil end
|
||||||
|
if fmt_is("latex") then
|
||||||
|
return pandoc.RawBlock(el.format, replace_gls(el.text, "latex"))
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function usage(key, display)
|
||||||
|
local r = resolve(key)
|
||||||
|
if r == nil then
|
||||||
|
warn_key(key)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local out = pandoc.Inlines({})
|
||||||
|
if fmt_is("latex") then
|
||||||
|
out:insert(pandoc.RawInline("latex", "\\hyperlink{" .. r.id .. "}{"))
|
||||||
|
out:extend(display)
|
||||||
|
out:insert(pandoc.RawInline("latex", "}"))
|
||||||
|
elseif fmt_is("html") then
|
||||||
|
out:insert(pandoc.RawInline("html",
|
||||||
|
'<a class="gls-sym" data-gls-key="' .. key .. '" href="' .. r.href .. '">'))
|
||||||
|
out:extend(display)
|
||||||
|
out:insert(pandoc.RawInline("html", "</a>"))
|
||||||
|
else
|
||||||
|
return display
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function Span(el)
|
||||||
|
-- pandoc's LaTeX reader turns \gls{key} into this span (glossaries pkg)
|
||||||
|
local acr = el.attributes["acronym-label"]
|
||||||
|
if acr ~= nil then
|
||||||
|
local r = resolve(acr)
|
||||||
|
if r == nil then
|
||||||
|
warn_key(acr)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return usage(acr, pandoc.Inlines({ pandoc.Math("InlineMath", math_display(r)) }))
|
||||||
|
end
|
||||||
|
if not el.classes:includes("gls") then return nil end
|
||||||
|
local key = el.attributes.key or pandoc.utils.stringify(el.content)
|
||||||
|
local display
|
||||||
|
if el.attributes.key ~= nil then
|
||||||
|
display = el.content
|
||||||
|
else
|
||||||
|
local r = resolve(key)
|
||||||
|
display = r and r.term:clone() or el.content
|
||||||
|
end
|
||||||
|
return usage(key, display)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function RawInline(el)
|
||||||
|
if el.format ~= "tex" and el.format ~= "latex" then return nil end
|
||||||
|
local key = el.text:match("^\\gls%s*{(.-)}$")
|
||||||
|
if key == nil then return nil end
|
||||||
|
local r = resolve(key)
|
||||||
|
if r == nil then return nil end
|
||||||
|
return usage(key, pandoc.Inlines({ pandoc.Math("InlineMath", math_display(r)) }))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- head line "sym — **term**" (+ latex hypertarget when with_anchor)
|
||||||
|
local function head_line(id, sym, term, with_anchor)
|
||||||
|
local head = pandoc.Inlines({})
|
||||||
|
if with_anchor and fmt_is("latex") then
|
||||||
|
head:insert(pandoc.RawInline("latex", "\\hypertarget{" .. id .. "}{}"))
|
||||||
|
end
|
||||||
|
if sym ~= nil then
|
||||||
|
head:insert(pandoc.Math("InlineMath", sym))
|
||||||
|
head:insert(pandoc.Str("\u{2002}—\u{2002}"))
|
||||||
|
end
|
||||||
|
head:insert(pandoc.Strong(term:clone()))
|
||||||
|
return pandoc.Para(head)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function prop_div(base, pk, p, with_anchor)
|
||||||
|
local id = anchor(base .. "." .. pk)
|
||||||
|
local blocks = pandoc.Blocks({ head_line(id, p.sym, p.term, with_anchor) })
|
||||||
|
blocks:extend(p.def:clone())
|
||||||
|
return pandoc.Div(blocks,
|
||||||
|
pandoc.Attr(with_anchor and id or "", { "gls-entry", "gls-prop-entry" }))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- transform an in-place .gls-def into its rendered definition card
|
||||||
|
local function render_inline_def(el)
|
||||||
|
local key = el.attributes.key
|
||||||
|
if key == nil then return nil end
|
||||||
|
local e = glossary[key]
|
||||||
|
if e == nil or not e.inline then return nil end
|
||||||
|
local id = anchor(key)
|
||||||
|
local blocks = pandoc.Blocks({ head_line(id, e.sym, e.term, true) })
|
||||||
|
blocks:extend(e.def:clone())
|
||||||
|
for _, pk in ipairs(e.porder) do
|
||||||
|
if not e.props[pk].standalone then -- standalone props render at their own spot
|
||||||
|
blocks:insert(prop_div(key, pk, e.props[pk], true))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return pandoc.Div(blocks, pandoc.Attr(id, { "gls-entry", "gls-inline" }))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ::: {.glossary} ::: -> rendered list
|
||||||
|
local function render_list(el)
|
||||||
|
table.sort(keys)
|
||||||
|
local blocks = pandoc.Blocks({})
|
||||||
|
for _, key in ipairs(keys) do
|
||||||
|
local e = glossary[key]
|
||||||
|
local id = anchor(key)
|
||||||
|
-- inline-defined entries keep their canonical anchor in the flow;
|
||||||
|
-- the list clone links back instead of re-anchoring
|
||||||
|
local with_anchor = not e.inline
|
||||||
|
local entry = pandoc.Blocks({ head_line(id, e.sym, e.term, with_anchor) })
|
||||||
|
entry:extend(e.def:clone())
|
||||||
|
if e.inline then
|
||||||
|
for _, pk in ipairs(e.porder) do
|
||||||
|
local p = e.props[pk]
|
||||||
|
local line = pandoc.Inlines({})
|
||||||
|
local pid = anchor(key .. "." .. pk)
|
||||||
|
if fmt_is("latex") then
|
||||||
|
line:insert(pandoc.RawInline("latex", "\\hyperlink{" .. pid .. "}{"))
|
||||||
|
elseif fmt_is("html") then
|
||||||
|
line:insert(pandoc.RawInline("html",
|
||||||
|
'<a class="gls-back" href="#' .. pid .. '">'))
|
||||||
|
end
|
||||||
|
if p.sym ~= nil then
|
||||||
|
line:insert(pandoc.Math("InlineMath", p.sym))
|
||||||
|
line:insert(pandoc.Str("\u{2002}"))
|
||||||
|
end
|
||||||
|
line:extend(p.term:clone())
|
||||||
|
if fmt_is("latex") then
|
||||||
|
line:insert(pandoc.RawInline("latex", "}"))
|
||||||
|
elseif fmt_is("html") then
|
||||||
|
line:insert(pandoc.RawInline("html", "</a>"))
|
||||||
|
end
|
||||||
|
entry:insert(pandoc.Div(pandoc.Blocks({ pandoc.Plain(line) }),
|
||||||
|
pandoc.Attr("", { "gls-prop-line" })))
|
||||||
|
end
|
||||||
|
local back = pandoc.Inlines({})
|
||||||
|
if fmt_is("latex") then
|
||||||
|
back:insert(pandoc.RawInline("latex", "\\hyperlink{" .. id .. "}{"))
|
||||||
|
back:insert(pandoc.Str("→ definition in context"))
|
||||||
|
back:insert(pandoc.RawInline("latex", "}"))
|
||||||
|
else
|
||||||
|
back:insert(pandoc.RawInline("html",
|
||||||
|
'<a class="gls-back" href="#' .. id .. '">'))
|
||||||
|
back:insert(pandoc.Str("→ definition in context"))
|
||||||
|
back:insert(pandoc.RawInline("html", "</a>"))
|
||||||
|
end
|
||||||
|
entry:insert(pandoc.Para(back))
|
||||||
|
else
|
||||||
|
for _, pk in ipairs(e.porder) do
|
||||||
|
entry:insert(prop_div(key, pk, e.props[pk], true))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
blocks:insert(pandoc.Div(entry,
|
||||||
|
pandoc.Attr(with_anchor and id or "", { "gls-entry" })))
|
||||||
|
end
|
||||||
|
return pandoc.Div(blocks, pandoc.Attr(el.identifier, { "glossary" }))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- transform a standalone .gls-prop into its rendered card
|
||||||
|
local function render_standalone_prop(el)
|
||||||
|
local key = el.attributes.key
|
||||||
|
local base, prop
|
||||||
|
if el.attributes.of ~= nil then
|
||||||
|
base, prop = el.attributes.of, el.attributes.key
|
||||||
|
elseif key ~= nil then
|
||||||
|
base, prop = key:match("^([^%.]+)%.(.+)$")
|
||||||
|
end
|
||||||
|
if base == nil then return nil end -- nested style: parent handles it
|
||||||
|
local e = glossary[base]
|
||||||
|
local p = e and e.props[prop]
|
||||||
|
if p == nil then return nil end
|
||||||
|
local d = prop_div(base, prop, p, true)
|
||||||
|
d.classes:insert("gls-standalone")
|
||||||
|
return d
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
------------------------------------------------------------------ tooltip data
|
||||||
|
|
||||||
|
local function js_escape(s)
|
||||||
|
s = s:gsub("\\", "\\\\"):gsub('"', '\\"')
|
||||||
|
s = s:gsub("\n", "\\n"):gsub("\r", ""):gsub("<%/", "<\\/")
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
|
||||||
|
local function blocks_to_html(blocks)
|
||||||
|
if blocks == nil or #blocks == 0 then return "" end
|
||||||
|
local ok, html = pcall(pandoc.write, pandoc.Pandoc(blocks), "html")
|
||||||
|
if not ok then return "" end
|
||||||
|
return html
|
||||||
|
end
|
||||||
|
|
||||||
|
local function head_html(sym, term)
|
||||||
|
local ins = pandoc.Inlines({})
|
||||||
|
if sym ~= nil then
|
||||||
|
ins:insert(pandoc.Math("InlineMath", sym))
|
||||||
|
ins:insert(pandoc.Str("\u{2002}\u{2014}\u{2002}"))
|
||||||
|
end
|
||||||
|
ins:extend(term:clone())
|
||||||
|
return blocks_to_html(pandoc.Blocks({ pandoc.Para(ins) }))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- make every entry's definition available to the tooltip on any page
|
||||||
|
local function inject_data(doc)
|
||||||
|
if not fmt_is("html") then return doc end
|
||||||
|
if quarto == nil or quarto.doc == nil then return doc end
|
||||||
|
local parts = {}
|
||||||
|
for key, e in pairs(glossary) do
|
||||||
|
parts[#parts + 1] = '"' .. js_escape(key) .. '":"'
|
||||||
|
.. js_escape(head_html(e.sym, e.term) .. blocks_to_html(e.def)) .. '"'
|
||||||
|
for pk, p in pairs(e.props) do
|
||||||
|
parts[#parts + 1] = '"' .. js_escape(key .. "." .. pk) .. '":"'
|
||||||
|
.. js_escape(head_html(p.sym, p.term) .. blocks_to_html(p.def)) .. '"'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #parts == 0 then return doc end
|
||||||
|
quarto.doc.include_text("after-body",
|
||||||
|
"<script>window.__glsData = {" .. table.concat(parts, ",") .. "};</script>")
|
||||||
|
return doc
|
||||||
|
end
|
||||||
|
|
||||||
|
local function Div(el)
|
||||||
|
if el.classes:includes("gls-def") then
|
||||||
|
return render_inline_def(el)
|
||||||
|
elseif el.classes:includes("gls-prop") then
|
||||||
|
return render_standalone_prop(el)
|
||||||
|
elseif el.classes:includes("glossary") then
|
||||||
|
return render_list(el)
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
{ Meta = read_meta },
|
||||||
|
{ Div = register_inline, Pandoc = attach_pending },
|
||||||
|
{
|
||||||
|
Math = Math,
|
||||||
|
RawBlock = RawBlock,
|
||||||
|
RawInline = RawInline,
|
||||||
|
Span = Span,
|
||||||
|
Div = Div,
|
||||||
|
Pandoc = inject_data,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
-- latex-tables.lua — write tables in LaTeX, render them in HTML too.
|
||||||
|
--
|
||||||
|
-- \begin{tabular}{|l|c|r|}
|
||||||
|
-- \hline
|
||||||
|
-- Quantity & Symbol & Unit \\
|
||||||
|
-- \hline
|
||||||
|
-- pressure & $p$ & Pa \\
|
||||||
|
-- density & $\rho$ & kg/m$^3$ \\
|
||||||
|
-- \hline
|
||||||
|
-- \end{tabular}
|
||||||
|
--
|
||||||
|
-- Supported: l c r, p{w} m{w} b{w}, X (tabularx), *{n}{...} repetition,
|
||||||
|
-- @{...} and >{...}<{...} (ignored), | vertical rules, \hline, \cline{a-b},
|
||||||
|
-- booktabs \toprule \midrule \bottomrule \cmidrule{a-b}, \multicolumn,
|
||||||
|
-- \multirow, and the \begin{table} float wrapper with \caption and \label.
|
||||||
|
--
|
||||||
|
-- PDF: passed through untouched (it is already LaTeX).
|
||||||
|
-- HTML: converted to a real <table>; cell contents are parsed as LaTeX, so
|
||||||
|
-- $math$, \textbf{...} etc. work.
|
||||||
|
--
|
||||||
|
-- Header rows: everything above the first rule that follows row 1 becomes
|
||||||
|
-- <thead>. Put "% no-header" inside the environment to disable that.
|
||||||
|
|
||||||
|
local ENVS = { tabular = true, tabularx = true, longtable = true,
|
||||||
|
["tabular*"] = true }
|
||||||
|
|
||||||
|
local function fmt_is(name)
|
||||||
|
if quarto and quarto.doc and quarto.doc.is_format then
|
||||||
|
return quarto.doc.is_format(name)
|
||||||
|
end
|
||||||
|
return FORMAT ~= nil and FORMAT:match(name) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ utils
|
||||||
|
|
||||||
|
local function trim(s)
|
||||||
|
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- index of the "}" matching the "{" at open_idx
|
||||||
|
local function find_close(s, open_idx)
|
||||||
|
local depth, i, n = 0, open_idx, #s
|
||||||
|
while i <= n do
|
||||||
|
local c = s:sub(i, i)
|
||||||
|
if c == "\\" then
|
||||||
|
i = i + 2
|
||||||
|
else
|
||||||
|
if c == "{" then
|
||||||
|
depth = depth + 1
|
||||||
|
elseif c == "}" then
|
||||||
|
depth = depth - 1
|
||||||
|
if depth == 0 then return i end
|
||||||
|
end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- split on every literal `sep` sitting at brace depth 0 and outside any
|
||||||
|
-- nested environment (so \\ and & inside \begin{cases}/{array}/{pmatrix}
|
||||||
|
-- in a cell are left alone)
|
||||||
|
local function split_top(s, sep)
|
||||||
|
local parts, depth, env, i, start = {}, 0, 0, 1, 1
|
||||||
|
local n, k = #s, #sep
|
||||||
|
while i <= n do
|
||||||
|
if depth == 0 and env == 0 and s:sub(i, i + k - 1) == sep then
|
||||||
|
parts[#parts + 1] = s:sub(start, i - 1)
|
||||||
|
i = i + k
|
||||||
|
start = i
|
||||||
|
else
|
||||||
|
local c = s:sub(i, i)
|
||||||
|
if c == "\\" then
|
||||||
|
if s:find("^\\begin%s*{", i) then
|
||||||
|
env = env + 1
|
||||||
|
i = i + 6
|
||||||
|
elseif s:find("^\\end%s*{", i) then
|
||||||
|
env = math.max(0, env - 1)
|
||||||
|
i = i + 4
|
||||||
|
else
|
||||||
|
i = i + 2
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if c == "{" then
|
||||||
|
depth = depth + 1
|
||||||
|
elseif c == "}" then
|
||||||
|
depth = math.max(0, depth - 1)
|
||||||
|
end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
parts[#parts + 1] = s:sub(start)
|
||||||
|
return parts
|
||||||
|
end
|
||||||
|
|
||||||
|
-- strip % comments (but keep \%)
|
||||||
|
local function strip_comments(s)
|
||||||
|
local out, i, n = {}, 1, #s
|
||||||
|
while i <= n do
|
||||||
|
local c = s:sub(i, i)
|
||||||
|
if c == "\\" then
|
||||||
|
out[#out + 1] = s:sub(i, i + 1)
|
||||||
|
i = i + 2
|
||||||
|
elseif c == "%" then
|
||||||
|
local nl = s:find("\n", i, true)
|
||||||
|
if nl == nil then break end
|
||||||
|
i = nl -- keep the newline
|
||||||
|
else
|
||||||
|
out[#out + 1] = c
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(out)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- LaTeX length -> CSS length
|
||||||
|
local function css_len(w)
|
||||||
|
w = trim(w)
|
||||||
|
local frac, rel = w:match("^([%d%.]+)\\(%a+)$")
|
||||||
|
if frac ~= nil and (rel == "textwidth" or rel == "linewidth"
|
||||||
|
or rel == "columnwidth") then
|
||||||
|
return string.format("%.4g%%", tonumber(frac) * 100)
|
||||||
|
end
|
||||||
|
if w:match("^\\%a+$") then return "100%" end
|
||||||
|
local num, unit = w:match("^([%-%d%.]+)%s*(%a+)$")
|
||||||
|
if num ~= nil then
|
||||||
|
if unit == "pt" then return num .. "pt" end
|
||||||
|
if unit == "cm" or unit == "mm" or unit == "in"
|
||||||
|
or unit == "em" or unit == "ex" then
|
||||||
|
return num .. unit
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ colspec
|
||||||
|
|
||||||
|
-- *{3}{c|} -> c|c|c|
|
||||||
|
local function expand_star(spec)
|
||||||
|
local guard = 0
|
||||||
|
while guard < 20 do
|
||||||
|
guard = guard + 1
|
||||||
|
local st, _, cnt, body = spec:find("%*%s*(%b{})%s*(%b{})")
|
||||||
|
if st == nil then break end
|
||||||
|
local n = tonumber(cnt:sub(2, -2))
|
||||||
|
local inner = body:sub(2, -2)
|
||||||
|
if n == nil then break end
|
||||||
|
spec = spec:sub(1, st - 1) .. string.rep(inner, n)
|
||||||
|
.. spec:sub(st + #cnt + #body + (spec:sub(st + 1, st + 1) == " " and 1 or 0))
|
||||||
|
-- recompute safely: rebuild from the matched span
|
||||||
|
local _, en = spec:find(inner, st, true)
|
||||||
|
if en == nil then break end
|
||||||
|
end
|
||||||
|
return spec
|
||||||
|
end
|
||||||
|
|
||||||
|
-- returns list of { align, left, right, width }
|
||||||
|
local function parse_colspec(spec)
|
||||||
|
spec = expand_star(spec)
|
||||||
|
local cols, pending, i, n = {}, 0, 1, #spec
|
||||||
|
while i <= n do
|
||||||
|
local c = spec:sub(i, i)
|
||||||
|
if c == "|" then
|
||||||
|
pending = pending + 1
|
||||||
|
i = i + 1
|
||||||
|
elseif c == " " or c == "\n" or c == "\t" then
|
||||||
|
i = i + 1
|
||||||
|
elseif c == "@" or c == "!" or c == ">" or c == "<" then
|
||||||
|
local br = spec:find("{", i, true)
|
||||||
|
local close = br and find_close(spec, br)
|
||||||
|
i = close and (close + 1) or (i + 1)
|
||||||
|
elseif c == "l" or c == "c" or c == "r" then
|
||||||
|
cols[#cols + 1] = { align = c, left = pending, right = 0 }
|
||||||
|
pending = 0
|
||||||
|
i = i + 1
|
||||||
|
elseif c == "p" or c == "m" or c == "b" then
|
||||||
|
local br = spec:find("{", i, true)
|
||||||
|
local close = br and find_close(spec, br)
|
||||||
|
local w = close and spec:sub(br + 1, close - 1) or nil
|
||||||
|
cols[#cols + 1] = { align = "l", left = pending, right = 0,
|
||||||
|
width = w and css_len(w) or nil }
|
||||||
|
pending = 0
|
||||||
|
i = close and (close + 1) or (i + 1)
|
||||||
|
elseif c == "X" then
|
||||||
|
cols[#cols + 1] = { align = "l", left = pending, right = 0, width = "auto" }
|
||||||
|
pending = 0
|
||||||
|
i = i + 1
|
||||||
|
else
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #cols > 0 and pending > 0 then cols[#cols].right = pending end
|
||||||
|
return cols
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ rows
|
||||||
|
|
||||||
|
local RULE_PAT = {
|
||||||
|
{ pat = "^\\toprule%s*(%b[])?", kind = "thick" },
|
||||||
|
{ pat = "^\\toprule", kind = "thick" },
|
||||||
|
{ pat = "^\\bottomrule", kind = "thick" },
|
||||||
|
{ pat = "^\\midrule", kind = "thin" },
|
||||||
|
{ pat = "^\\hline", kind = "thin" },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- pull leading rule commands off a row segment
|
||||||
|
local function take_rules(seg)
|
||||||
|
local rules = { all = nil, ranges = {}, count = 0 }
|
||||||
|
local changed = true
|
||||||
|
while changed do
|
||||||
|
changed = false
|
||||||
|
seg = seg:gsub("^%s+", "")
|
||||||
|
-- \noalign{...} / \addlinespace / spacing args: drop
|
||||||
|
local br = seg:match("^\\noalign%s*%b{}")
|
||||||
|
if br then seg = seg:sub(#br + 1); changed = true end
|
||||||
|
local al = seg:match("^\\addlinespace%s*%b[]") or seg:match("^\\addlinespace")
|
||||||
|
if al then seg = seg:sub(#al + 1); changed = true end
|
||||||
|
local opt = seg:match("^%b[]")
|
||||||
|
if opt then seg = seg:sub(#opt + 1); changed = true end
|
||||||
|
for _, r in ipairs(RULE_PAT) do
|
||||||
|
local m = seg:match(r.pat)
|
||||||
|
if m then
|
||||||
|
rules.count = rules.count + 1
|
||||||
|
rules.all = (rules.all == "thick" or r.kind == "thick") and "thick" or "thin"
|
||||||
|
if rules.count > 1 then rules.all = "double" end
|
||||||
|
seg = seg:sub(#m + 1)
|
||||||
|
changed = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- \cline{a-b} and \cmidrule(lr){a-b}
|
||||||
|
local head, range = seg:match("^(\\cline%s*(%b{}))")
|
||||||
|
if head == nil then
|
||||||
|
local h2, _, r2 = seg:match("^(\\cmidrule%s*(%b())%s*(%b{}))")
|
||||||
|
if h2 then head, range = h2, r2 end
|
||||||
|
end
|
||||||
|
if head == nil then
|
||||||
|
local h3, r3 = seg:match("^(\\cmidrule%s*(%b{}))")
|
||||||
|
if h3 then head, range = h3, r3 end
|
||||||
|
end
|
||||||
|
if head ~= nil and range ~= nil then
|
||||||
|
local a, b = range:sub(2, -2):match("^%s*(%d+)%s*%-%s*(%d+)%s*$")
|
||||||
|
if a then
|
||||||
|
rules.ranges[#rules.ranges + 1] = { tonumber(a), tonumber(b) }
|
||||||
|
end
|
||||||
|
seg = seg:sub(#head + 1)
|
||||||
|
changed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return seg, rules
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_rule(rules)
|
||||||
|
return rules.all ~= nil or #rules.ranges > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- \multicolumn{n}{spec}{content} / \multirow{n}{w}{content}
|
||||||
|
local function parse_cell(txt)
|
||||||
|
local cell = { colspan = 1, rowspan = 1, body = txt, spec = nil }
|
||||||
|
local t = trim(txt)
|
||||||
|
local head, a, b = t:match("^(\\multicolumn%s*(%b{})%s*(%b{}))")
|
||||||
|
if head then
|
||||||
|
local br = t:find("{", #head + 1, true)
|
||||||
|
local close = br and find_close(t, br)
|
||||||
|
if close then
|
||||||
|
cell.colspan = tonumber(a:sub(2, -2)) or 1
|
||||||
|
cell.spec = b:sub(2, -2)
|
||||||
|
cell.body = t:sub(br + 1, close - 1)
|
||||||
|
t = trim(t:sub(close + 1))
|
||||||
|
if t ~= "" then cell.body = cell.body .. " " .. t end
|
||||||
|
return cell
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local mhead, mn = t:match("^(\\multirow%s*(%b{}))")
|
||||||
|
if mhead then
|
||||||
|
-- optional [vpos], then {width}{content}
|
||||||
|
local rest = t:sub(#mhead + 1)
|
||||||
|
local opt = rest:match("^%b[]")
|
||||||
|
if opt then rest = rest:sub(#opt + 1) end
|
||||||
|
local w = rest:match("^%s*%b{}")
|
||||||
|
if w then
|
||||||
|
rest = rest:sub(#rest:match("^%s*") + #w + 1)
|
||||||
|
local br = rest:find("{", 1, true)
|
||||||
|
local close = br and find_close(rest, br)
|
||||||
|
if close then
|
||||||
|
cell.rowspan = tonumber(mn:sub(2, -2)) or 1
|
||||||
|
cell.body = rest:sub(br + 1, close - 1)
|
||||||
|
return cell
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return cell
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_body(body)
|
||||||
|
local rows, trailing = {}, nil
|
||||||
|
local segs = split_top(body, "\\\\")
|
||||||
|
for idx, raw in ipairs(segs) do
|
||||||
|
local seg, rules = take_rules(raw)
|
||||||
|
seg = trim(seg)
|
||||||
|
if seg == "" then
|
||||||
|
if idx == #segs then
|
||||||
|
trailing = rules
|
||||||
|
elseif #rows > 0 and has_rule(rules) then
|
||||||
|
-- a rule on its own line between rows: attach below previous row
|
||||||
|
rows[#rows].below = rules
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local cells = {}
|
||||||
|
for _, c in ipairs(split_top(seg, "&")) do
|
||||||
|
cells[#cells + 1] = parse_cell(c)
|
||||||
|
end
|
||||||
|
rows[#rows + 1] = { cells = cells, above = rules }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return rows, trailing
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ html
|
||||||
|
|
||||||
|
local function cell_inlines(txt)
|
||||||
|
txt = trim(txt)
|
||||||
|
if txt == "" then return pandoc.Inlines({}) end
|
||||||
|
local ok, doc = pcall(pandoc.read, txt, "latex")
|
||||||
|
if ok and doc and #doc.blocks > 0 then
|
||||||
|
return pandoc.utils.blocks_to_inlines(doc.blocks)
|
||||||
|
end
|
||||||
|
return pandoc.Inlines({ pandoc.Str(txt) })
|
||||||
|
end
|
||||||
|
|
||||||
|
local function rule_class(kind, where)
|
||||||
|
if kind == "thick" then return "ltx-" .. where .. "2" end
|
||||||
|
if kind == "double" then return "ltx-" .. where .. "d" end
|
||||||
|
if kind == "thin" then return "ltx-" .. where .. "1" end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local ALIGN = { l = "AlignLeft", c = "AlignCenter", r = "AlignRight" }
|
||||||
|
|
||||||
|
-- build one pandoc.Row.
|
||||||
|
-- `pending` maps column -> number of further rows still covered by a
|
||||||
|
-- \multirow above. LaTeX writes an empty placeholder cell for those, but
|
||||||
|
-- HTML's rowspan already covers them, so they must be consumed, not emitted.
|
||||||
|
local function build_row(cols, row, ri, nrows, trailing, pending)
|
||||||
|
local cells = pandoc.List({})
|
||||||
|
local ci = 1
|
||||||
|
local input, k = row.cells, 1
|
||||||
|
while k <= #input do
|
||||||
|
-- skip columns still covered from above
|
||||||
|
while (pending[ci] or 0) > 0 do
|
||||||
|
pending[ci] = pending[ci] - 1
|
||||||
|
ci = ci + 1
|
||||||
|
if trim(input[k].body) == "" and input[k].colspan == 1
|
||||||
|
and input[k].rowspan == 1 then
|
||||||
|
k = k + 1 -- eat the placeholder
|
||||||
|
if k > #input then break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if k > #input then break end
|
||||||
|
local cell = input[k]
|
||||||
|
k = k + 1
|
||||||
|
local col = cols[ci] or { align = "l", left = 0, right = 0 }
|
||||||
|
local last = cols[math.min(ci + cell.colspan - 1, #cols)] or col
|
||||||
|
local align, left, right = col.align, col.left, last.right
|
||||||
|
local width = col.width
|
||||||
|
if cell.spec ~= nil then
|
||||||
|
local sub = parse_colspec(cell.spec)
|
||||||
|
if #sub > 0 then
|
||||||
|
align = sub[1].align
|
||||||
|
left = sub[1].left
|
||||||
|
right = sub[#sub].right
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local cls = pandoc.List({ "ltx-" .. align })
|
||||||
|
if left > 0 then cls:insert("ltx-bl") end
|
||||||
|
if right > 0 then cls:insert("ltx-br") end
|
||||||
|
|
||||||
|
local ra = row.above
|
||||||
|
if ra.all then
|
||||||
|
cls:insert(rule_class(ra.all, "t"))
|
||||||
|
else
|
||||||
|
for _, rg in ipairs(ra.ranges) do
|
||||||
|
if ci >= rg[1] and ci <= rg[2] then cls:insert("ltx-t1") break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local rb = row.below or (ri == nrows and trailing or nil)
|
||||||
|
if rb then
|
||||||
|
if rb.all then
|
||||||
|
cls:insert(rule_class(rb.all, "b"))
|
||||||
|
else
|
||||||
|
for _, rg in ipairs(rb.ranges) do
|
||||||
|
if ci >= rg[1] and ci <= rg[2] then cls:insert("ltx-b1") break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local attrs = {}
|
||||||
|
if width and width ~= "auto" then attrs.style = "width:" .. width end
|
||||||
|
local content = pandoc.Blocks({ pandoc.Plain(cell_inlines(cell.body)) })
|
||||||
|
cells:insert(pandoc.Cell(content, ALIGN[align] or "AlignDefault",
|
||||||
|
cell.rowspan, cell.colspan, pandoc.Attr("", cls, attrs)))
|
||||||
|
if cell.rowspan > 1 then
|
||||||
|
for c = ci, ci + cell.colspan - 1 do
|
||||||
|
pending[c] = (pending[c] or 0) + cell.rowspan - 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
ci = ci + cell.colspan
|
||||||
|
end
|
||||||
|
return pandoc.Row(cells)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function render_table(cols, rows, trailing, header_end, id, caption)
|
||||||
|
local colspecs = pandoc.List({})
|
||||||
|
for _, c in ipairs(cols) do
|
||||||
|
colspecs:insert({ ALIGN[c.align] or "AlignDefault", nil })
|
||||||
|
end
|
||||||
|
|
||||||
|
local head_rows, body_rows = pandoc.List({}), pandoc.List({})
|
||||||
|
local pending = {}
|
||||||
|
for ri, row in ipairs(rows) do
|
||||||
|
local r = build_row(cols, row, ri, #rows, trailing, pending)
|
||||||
|
if header_end and ri <= header_end then
|
||||||
|
head_rows:insert(r)
|
||||||
|
else
|
||||||
|
body_rows:insert(r)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local cap = pandoc.Caption({})
|
||||||
|
if caption ~= nil then
|
||||||
|
cap = pandoc.Caption(pandoc.Blocks({ pandoc.Plain(cell_inlines(caption)) }))
|
||||||
|
end
|
||||||
|
|
||||||
|
return pandoc.Table(
|
||||||
|
cap,
|
||||||
|
colspecs,
|
||||||
|
pandoc.TableHead(head_rows),
|
||||||
|
{ { attr = pandoc.Attr(), body = body_rows,
|
||||||
|
head = pandoc.List({}), row_head_columns = 0 } },
|
||||||
|
pandoc.TableFoot(),
|
||||||
|
pandoc.Attr(id or "", { "ltx-table" })
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
------------------------------------------------------------------ transform
|
||||||
|
|
||||||
|
local function find_env(txt, name)
|
||||||
|
local st = txt:find("\\begin%s*{" .. name .. "}")
|
||||||
|
if st == nil then return nil end
|
||||||
|
local _, be = txt:find("\\begin%s*{" .. name .. "}", st)
|
||||||
|
local es, ee = txt:find("\\end%s*{" .. name .. "}", be)
|
||||||
|
if es == nil then return nil end
|
||||||
|
return st, be, es, ee
|
||||||
|
end
|
||||||
|
|
||||||
|
local function tabular_to_table(txt, id, caption)
|
||||||
|
local name
|
||||||
|
for env in pairs(ENVS) do
|
||||||
|
if txt:find("\\begin%s*{" .. env:gsub("%*", "%%*") .. "}") then
|
||||||
|
name = env
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if name == nil then return nil end
|
||||||
|
local esc_name = name:gsub("%*", "%%*")
|
||||||
|
local _, be, es = find_env(txt, esc_name)
|
||||||
|
if be == nil then return nil end
|
||||||
|
local rest = txt:sub(be + 1)
|
||||||
|
local inner_end = es - be - 1
|
||||||
|
local inner = rest:sub(1, inner_end)
|
||||||
|
|
||||||
|
-- tabularx/tabular* take a width argument first
|
||||||
|
if name == "tabularx" or name == "tabular*" then
|
||||||
|
local w = inner:match("^%s*%b{}")
|
||||||
|
if w then inner = inner:sub(#inner:match("^%s*") + #w + 1) end
|
||||||
|
end
|
||||||
|
-- optional [t]/[b] positioning
|
||||||
|
local pos = inner:match("^%s*%b[]")
|
||||||
|
if pos then inner = inner:sub(#inner:match("^%s*") + #pos + 1) end
|
||||||
|
|
||||||
|
local specm = inner:match("^%s*%b{}")
|
||||||
|
if specm == nil then return nil end
|
||||||
|
local body = inner:sub(#inner:match("^%s*") + #specm + 1)
|
||||||
|
local spec = specm:sub(2, -2)
|
||||||
|
|
||||||
|
local no_header = body:find("no%-header") ~= nil
|
||||||
|
body = strip_comments(body)
|
||||||
|
local cols = parse_colspec(spec)
|
||||||
|
if #cols == 0 then return nil end
|
||||||
|
local rows, trailing = parse_body(body)
|
||||||
|
if #rows == 0 then return nil end
|
||||||
|
|
||||||
|
local header_end = 0
|
||||||
|
if not no_header then
|
||||||
|
for i = 2, #rows do
|
||||||
|
if has_rule(rows[i].above) or (rows[i - 1].below and has_rule(rows[i - 1].below)) then
|
||||||
|
header_end = i - 1
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return render_table(cols, rows, trailing, header_end, id, caption)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- \begin{table} ... \caption{...} \label{...} ... \end{table}
|
||||||
|
local function float_parts(txt)
|
||||||
|
if not txt:find("\\begin%s*{table%*?}") then return nil end
|
||||||
|
local cap = nil
|
||||||
|
local cs = txt:find("\\caption")
|
||||||
|
if cs then
|
||||||
|
local br = txt:find("{", cs, true)
|
||||||
|
local close = br and find_close(txt, br)
|
||||||
|
if close then cap = txt:sub(br + 1, close - 1) end
|
||||||
|
end
|
||||||
|
local lab = txt:match("\\label%s*{(.-)}")
|
||||||
|
return { caption = cap, label = lab }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function RawBlock(el)
|
||||||
|
if el.format ~= "tex" and el.format ~= "latex" then return nil end
|
||||||
|
local txt = el.text
|
||||||
|
local has_tab = false
|
||||||
|
for env in pairs(ENVS) do
|
||||||
|
if txt:find("\\begin%s*{" .. env:gsub("%*", "%%*") .. "}") then
|
||||||
|
has_tab = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not has_tab then return nil end
|
||||||
|
if fmt_is("latex") then return nil end -- PDF: it is already LaTeX
|
||||||
|
if not fmt_is("html") then return nil end
|
||||||
|
|
||||||
|
local float = float_parts(txt) or {}
|
||||||
|
local tbl = tabular_to_table(txt, float.label, float.caption)
|
||||||
|
if tbl == nil then
|
||||||
|
io.stderr:write("[latex-tables] could not parse a tabular; left as-is\n")
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return tbl
|
||||||
|
end
|
||||||
|
|
||||||
|
local function read_meta(meta)
|
||||||
|
if quarto ~= nil and quarto.doc ~= nil then
|
||||||
|
if fmt_is("html") then
|
||||||
|
quarto.doc.add_html_dependency({
|
||||||
|
name = "calc-latex-tables",
|
||||||
|
version = "1.0.0",
|
||||||
|
stylesheets = { "latex-tables.css" },
|
||||||
|
})
|
||||||
|
elseif fmt_is("latex") then
|
||||||
|
quarto.doc.use_latex_package("booktabs") -- \toprule etc.
|
||||||
|
quarto.doc.use_latex_package("multirow") -- \multirow
|
||||||
|
quarto.doc.use_latex_package("array")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return meta
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
{ Meta = read_meta },
|
||||||
|
{ RawBlock = RawBlock },
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user