238 lines
7.3 KiB
Lua
238 lines
7.3 KiB
Lua
-- 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 },
|
|
}
|