Change folder hierarchy
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user