-- scholatex --- a lightweight tag-based markup language for LuaLaTeX
-- Copyright (C) 2026 Gerard Dubard
--
-- This program is free software: you can redistribute it and/or modify it
-- under the terms of the GNU General Public License version 3 as published
-- by the Free Software Foundation. This program is distributed WITHOUT ANY
-- WARRANTY. See the LICENSE file or for
-- the full text of the license.
local U = require("scholatex-util")
local STYLE = require("scholatex-style")
local MATH = require("scholatex-math")
local sl = {}
sl.util = U
sl.style = STYLE
sl.math = MATH
sl._tags = {}
sl._blocks = {}
local ALIAS, MACRO, BLOCKALIAS
function sl.register_tag(name, fn)
if sl._tags[name] then
error("scholatex: tag '" .. name .. "' is already registered (name clash)")
end
sl._tags[name] = fn
end
function sl.register_block(name, fn)
if sl._blocks[name] then
error("scholatex: block '" .. name .. "' is already registered (name clash)")
end
sl._blocks[name] = fn
end
function sl.use(modname)
local m = require(modname)
if type(m) == "function" then m(sl) end
return m
end
local forward_text
local function lit(code, text)
if text ~= "" then
code[#code + 1] = "emit(" .. string.format("%q", text) .. ")\n"
end
end
-- A `let` whose name is a native keyword (a resolvable attribute) or a
-- registered block is silently ineffective: the native meaning always wins.
-- Warn the author so the dead definition does not go unnoticed.
local function warn_if_shadows(name, lineno)
local native = false
local ok, r = pcall(STYLE.resolve, name)
if ok and r then native = true end
if sl._blocks[name] or sl._tags[name] then native = true end
if native then
local where = lineno and (" (line " .. lineno .. ")") or ""
io.stderr:write("scholatex: warning: 'let " .. name .. "'" .. where
.. " shadows a built-in name and will be ignored; "
.. "the built-in '" .. name .. "' always takes precedence. "
.. "Use a different alias name.\n")
end
end
local function is_control_open(line)
return line:match("^%s*for%s+.+%sin%s+.+{%s*$")
or line:match("^%s*if%s+.+{%s*$")
or line:match("^%s*}%s*else%s*{%s*$")
or line:match("^%s*while%s+.+{%s*$")
end
local function lua_control(line)
-- for v in [a, b, c] { : iterate over an explicit list of items.
-- Items are taken verbatim as strings (bare words allowed: fig1.png).
local lv, llist = line:match("^%s*for%s+([%a_][%w_]*)%s+in%s+%[(.-)%]%s*{%s*$")
if lv then
local items = U.split_commas(llist)
local quoted = {}
for _, it in ipairs(items) do
quoted[#quoted + 1] = string.format("%q", it)
end
return ("for _, %s in ipairs({%s}) do"):format(lv, table.concat(quoted, ", "))
end
-- for v in a..b { : numeric range.
local v, a, b = line:match("^%s*for%s+([%a_][%w_]*)%s+in%s+(.-)%.%.(.-)%s*{%s*$")
if v then return ("for %s = %s, %s do"):format(v, a, b) end
local cond = line:match("^%s*if%s+(.-)%s*{%s*$")
if cond then return "if " .. cond .. " then" end
if line:match("^%s*}%s*else%s*{%s*$") then return "else" end
local wc = line:match("^%s*while%s+(.-)%s*{%s*$")
if wc then return "while " .. wc .. " do" end
return nil
end
local process_lines -- forward declaration (defined before build_lua)
local function mkapi(code)
return {
lit = function(t) lit(code, t) end,
raw = function(t) code[#code + 1] = t end,
forward_text = function(t) forward_text(code, t) end,
is_control_open = is_control_open,
lua_control = lua_control,
-- Processes a list of lines as document body: recognises blocks
-- (box, table...), for/if control, and text. This is what makes
-- blocks nestable (a box inside a box). Accepts either plain strings
-- or tagged {text=,lineno=} entries, so block handlers can pass back
-- the string lists they work with.
process_block = function(lines)
local norm = {}
for _, l in ipairs(lines) do
if type(l) == "string" then norm[#norm+1] = {text = l}
else norm[#norm+1] = l end
end
process_lines(code, norm)
end,
}
end
local function emit_tag(code, words_str, content)
local words = {}
for w in words_str:gmatch("%S+") do words[#words + 1] = w end
local head = words[1]
local handler = sl._tags[head]
if handler then
handler(mkapi(code), words, content)
return
end
if MACRO[head] then
local m = MACRO[head]
local args = {}
local depth, start, k, idx, n = 0, 1, 0, 1, #content
while idx <= n do
local c = content:sub(idx, idx)
if c == "\\" then idx = idx + 2
else
if c == "{" then depth = depth + 1
elseif c == "}" then depth = depth - 1
elseif c == "," and depth == 0 then
k = k + 1
args[k] = U.trim(content:sub(start, idx - 1))
start = idx + 1
end
idx = idx + 1
end
end
k = k + 1
args[k] = U.trim(content:sub(start))
local body = m.body
for pi, pname in ipairs(m.params) do
local repl = (args[pi] or ""):gsub("%%", "%%%%")
body = body:gsub("#" .. pname .. "%f[%W]", repl)
end
forward_text(code, body)
return
end
local outer, inner = STYLE.classify_split(words, ALIAS)
-- Open block-level wrappers once (these tolerate \par).
for _, e in ipairs(outer) do lit(code, e[1]) end
-- Split the content into paragraphs at top-level newlines, and wrap each
-- one in the inline-style stack. A \par is emitted BETWEEN paragraphs,
-- outside the style commands, so \textcolor/\textbf never enclose a \par.
-- A newline inside the braces thus behaves like one outside: it moves to
-- the next line in the PDF, keeping the surrounding tag's styles.
-- Each paragraph is stripped of the source's leading indentation (spaces
-- and tabs), and whitespace-only paragraphs -- produced by indenting the
-- opening "{" onto its own line, or a trailing newline before "}" -- are
-- dropped, so the way the source is laid out never adds blank lines.
local raw = U.split_top_newlines(content)
local paras = {}
for _, para in ipairs(raw) do
local clean = para:gsub("^[ \t]+", ""):gsub("[ \t]+$", "")
if clean ~= "" then paras[#paras + 1] = clean end
end
for pi, para in ipairs(paras) do
if pi > 1 then lit(code, " \\par ") end
for _, e in ipairs(inner) do lit(code, e[1]) end
forward_text(code, para)
for j = #inner, 1, -1 do lit(code, inner[j][2]) end
end
-- Close block-level wrappers.
for j = #outer, 1, -1 do lit(code, outer[j][2]) end
end
forward_text = function(code, s)
local i, n = 1, #s
local buf = {}
local function flush() lit(code, table.concat(buf)); buf = {} end
while i <= n do
local c = s:sub(i, i)
if c == "$" then
local close = s:find("$", i + 1, true)
if not close then
local where = sl._line and (" (line " .. sl._line .. ")") or ""
io.stderr:write("scholatex: warning: unterminated '$'" .. where
.. "; treating it as a literal dollar sign.\n")
buf[#buf + 1] = "\\$"; i = i + 1
goto continue
end
local inner = s:sub(i + 1, close - 1)
flush()
local function idx2alpha(num)
local t = {}
repeat
local r = (num - 1) % 26
t[#t+1] = string.char(97 + r)
num = (num - 1 - r) / 26
until num == 0
return table.concat(t)
end
local exprs = {}
local rebuilt, k = {}, 1
while k <= #inner do
local hash = inner:find("#", k, true)
if not hash then rebuilt[#rebuilt+1] = inner:sub(k); break end
rebuilt[#rebuilt+1] = inner:sub(k, hash - 1)
local expr, after
if inner:sub(hash+1, hash+1) == "{" then
expr, after = U.read_group(inner, hash + 1)
else
local name = inner:match("^#([%a_][%w_]*)", hash)
if name then
expr, after = name, hash + 1 + #name
else
-- Literal '#' in maths (not an interpolation): keep it and move on.
rebuilt[#rebuilt+1] = "\\#"
k = hash + 1
goto cont_hash
end
end
exprs[#exprs+1] = expr
rebuilt[#rebuilt+1] = "\\scholatexI" .. idx2alpha(#exprs) .. " "
k = after
::cont_hash::
end
local transformed = MATH.mathlite(table.concat(rebuilt))
lit(code, "$")
local function alpha2idx(a)
local num = 0
for ci = 1, #a do num = num * 26 + (a:byte(ci) - 96) end
return num
end
local p = 1
while p <= #transformed do
local a, b, alpha = transformed:find("\\scholatexI(%a+)%s?", p)
if not a then lit(code, transformed:sub(p)); break end
if a > p then lit(code, transformed:sub(p, a - 1)) end
code[#code+1] = "emit(_fmtm(" .. exprs[alpha2idx(alpha)] .. "))\n"
p = b + 1
end
lit(code, "$")
i = close + 1
elseif c == "\\" then
local nxt = s:sub(i + 1, i + 1)
if nxt == "#" then
buf[#buf + 1] = "\\#"; i = i + 2
elseif nxt == "{" or nxt == "}" then
buf[#buf + 1] = "\\" .. nxt; i = i + 2
elseif nxt == "<" then
buf[#buf + 1] = "\\textless{}"; i = i + 2
elseif nxt == ">" then
buf[#buf + 1] = "\\textgreater{}"; i = i + 2
else
buf[#buf + 1] = c; i = i + 1
end
elseif c == "<" then
local close = s:find(">", i + 1, true)
local words_str = s:sub(i + 1, close - 1)
local after_gt = close + 1
local probe = after_gt
while s:sub(probe, probe):match("[ \t]") do probe = probe + 1 end
flush()
if s:sub(probe, probe) == "{" then
local content, after = U.read_group(s, probe)
emit_tag(code, words_str, content)
i = after
else
local nl = s:find("\n", after_gt, true)
local stop = nl and (nl - 1) or #s
local content = s:sub(after_gt, stop)
emit_tag(code, words_str, content)
i = stop + 1
end
elseif c == "#" then
local nxt = s:sub(i + 1, i + 1)
if nxt == "{" then
local expr, after = U.read_group(s, i + 1)
flush()
code[#code + 1] = "emit(_fmt(" .. expr .. "))\n"
i = after
else
local name = s:match("^#([%a_][%w_]*)", i)
if name then
flush()
code[#code + 1] = "emit(_fmt(" .. name .. "))\n"
i = i + 1 + #name
else
-- '#' not followed by a name or a '{' is a literal hash
-- (e.g. "#3", "# ", "C#"); emit it as a LaTeX-safe \#.
buf[#buf + 1] = "\\#"; i = i + 1
end
end
else
if c == "\n" then
flush()
lit(code, " \\par ")
elseif c == "_" then buf[#buf + 1] = "\\_"
elseif c == "&" then buf[#buf + 1] = "\\&"
elseif c == "%" then buf[#buf + 1] = "\\%"
elseif c == "^" then buf[#buf + 1] = "\\textasciicircum{}"
elseif c == "~" then buf[#buf + 1] = "\\textasciitilde{}"
else buf[#buf + 1] = c end
i = i + 1
end
::continue::
end
flush()
end
local function tag_brace_delta(line)
local delta, i, n = 0, 1, #line
while i <= n do
local c = line:sub(i, i)
if c == "\\" then
i = i + 2
elseif c == "<" then
local close = line:find(">", i + 1, true)
if close then
local b = close + 1
while line:sub(b, b):match("%s") do b = b + 1 end
if line:sub(b, b) == "{" then
local depth, j = 0, b
while j <= n do
local d = line:sub(j, j)
if d == "\\" then j = j + 2
else
if d == "{" then depth = depth + 1
elseif d == "}" then depth = depth - 1 end
if depth == 0 then break end
j = j + 1
end
end
delta = delta + depth
i = (depth == 0) and (j + 1) or (n + 1)
else
i = close + 1
end
else
i = i + 1
end
elseif c == "#" and line:sub(i + 1, i + 1) == "{" then
local depth, j = 0, i + 1
while j <= n do
local d = line:sub(j, j)
if d == "\\" then j = j + 2
else
if d == "{" then depth = depth + 1
elseif d == "}" then depth = depth - 1 end
if depth == 0 then break end
j = j + 1
end
end
delta = delta + depth
i = (depth == 0) and (j + 1) or (n + 1)
else
i = i + 1
end
end
return delta
end
-- Processes a list of lines (document body or a block's content).
-- Recognises blocks, block aliases, for/if/while control, and text.
-- Factored out of build_lua so it can recurse -> nestable blocks.
process_lines = function(code, body_lines)
local idx, total = 1, #body_lines
while idx <= total do
local entry = body_lines[idx]
if entry.lineno then sl._line = entry.lineno end
if entry.var then
code[#code + 1] = "local " .. entry.var .. " = " .. entry.expr .. "\n"
idx = idx + 1
else
local line = entry.text
-- A block opener is normally "{" on one line. But options
-- like a grid template can span several lines: { . Only when the line OPENS a tag whose '>' is not
-- present at all on this line do we join following lines until ">{" is
-- found. Tags that already contain '>' (closed on the line, e.g.
-- "Title" or "{") are left untouched.
if line:match("^%s*<%a[%w_]*") and not line:find(">", 1, true) then
local j = idx
local joined = line
while j < total and not joined:match(">%s*{%s*$") do
j = j + 1
joined = joined .. "\n" .. (body_lines[j].text or "")
end
if joined:match(">%s*{%s*$") then
line = joined
idx = j -- consume the joined lines
end
end
local bname, bwords = line:match("^%s*<(%a[%w_]*)%s*(.-)>%s*{%s*$")
if bname and BLOCKALIAS[bname] then
local def = BLOCKALIAS[bname]
local opts = def.opts
-- Substitute call arguments (#param) into the alias options.
if #def.params > 0 then
local args = U.split_commas(bwords or "")
for pi, pname in ipairs(def.params) do
local repl = (args[pi] or ""):gsub("%%", "%%%%")
opts = opts:gsub("#" .. pname .. "%f[%W]", repl)
end
bwords = "" -- args consumed; extra options come from opts only
end
bwords = opts .. " " .. (bwords or "")
bname = def.block
end
if bname and sl._blocks[bname] then
local inner = {}
local depth = 1
idx = idx + 1
while idx <= total and depth > 0 do
local e = body_lines[idx]
local l = e.text
if l ~= nil then
-- Block-opening line <...>{ : +1 structural (its trailing {
-- is syntactic, not content to count via delta).
-- A lone } closing at depth 1 : end of the block.
-- Any other line : follow its braces (for/if { ... }).
if l:match("^%s*<%a[%w_]*.->%s*{%s*$") then
depth = depth + 1
inner[#inner+1] = e
elseif l:match("^%s*}%s*$") and depth == 1 then
depth = 0; idx = idx + 1; break
else
depth = depth + U.raw_brace_delta(l)
inner[#inner+1] = e
end
else
inner[#inner+1] = e
end
idx = idx + 1
end
-- Block handlers work with plain string lines. Convert the tagged
-- inner entries to strings before calling; line tracking is kept via
-- sl._line set above for the block-opening line.
local inner_str = {}
for _, e in ipairs(inner) do
inner_str[#inner_str+1] = (type(e) == "table") and e.text or e
end
sl._blocks[bname](mkapi(code), bwords or "", inner_str)
code[#code + 1] = 'emit(" \\\\par ")\n'
elseif line:match("^%s*}%s*$") then
code[#code + 1] = "end\n"
idx = idx + 1
elseif is_control_open(line) then
code[#code + 1] = lua_control(line) .. "\n"
idx = idx + 1
else
local chunk, delta = line, tag_brace_delta(line)
while delta > 0 and idx < total do
idx = idx + 1
local nxt = body_lines[idx].text or ""
chunk = chunk .. "\n" .. nxt
delta = delta + U.raw_brace_delta(nxt)
end
forward_text(code, chunk)
code[#code + 1] = 'emit(" \\\\par ")\n'
idx = idx + 1
end
end
end
end
local function build_lua(src)
local lang = (sl.config and sl.config.lang) or "fr"
-- Decimal separator for interpolated numbers (#x, #{expr}).
-- fr (default) : 3.5 -> "3,5" in text, "3{,}5" in math.
-- en : 3.5 stays "3.5" everywhere.
-- _fmt is for text, _fmtm for inside a $...$ span (needs {,} so TeX does
-- not insert a space after the comma). The behaviour matches the literal
-- decimals handled in sl-math, so #x and a typed number look identical.
local sep_txt = (lang == "en") and "." or ","
local sep_math = (lang == "en") and "." or "{,}"
local function q(s) return string.format("%q", s) end
local code = {
"local _parts = {}\n",
"local function emit(s) _parts[#_parts+1] = s end\n",
"local sqrt=math.sqrt; local floor=math.floor; local ceil=math.ceil\n",
"local abs=math.abs; local pi=math.pi; local max=math.max; local min=math.min\n",
"local function round(x,d) local m=10^(d or 0); return floor(x*m+0.5)/m end\n",
"local _SEPT=" .. q(sep_txt) .. "; local _SEPM=" .. q(sep_math) .. "\n",
"local function _fmt(v) if type(v)=='number' then return (tostring(v):gsub('%.',_SEPT,1)) end if v==nil then return '' end return tostring(v) end\n",
"local function _fmtm(v) if type(v)=='number' then return (tostring(v):gsub('%.',_SEPM,1)) end if v==nil then return '' end return tostring(v) end\n",
}
local body_lines = {}
local lineno = 0
for line in (src .. "\n"):gmatch("(.-)\n") do
lineno = lineno + 1
-- A line whose first non-space character is '%' is a comment (the LaTeX
-- convention). A worksheet line that must BEGIN with a literal percent
-- is written "\%..." -- the backslash is stripped here and the rest,
-- starting with '%', is kept as text (and escaped downstream). This is
-- the same '\%' escape LaTeX uses, so a lone leading '%' never silently
-- deletes a line the author meant to print.
do
local lead, rest = line:match("^(%s*)\\(%%.*)$")
if lead then
line = lead .. rest
elseif line:match("^%s*%%") then
goto continue
end
end
local name, params, rhs = line:match("^%s*let%s+([%a_][%w_]*)%s*{(.-)}%s*=%s*(.+)$")
if name then
warn_if_shadows(name, lineno)
local plist = {}
for p in params:gmatch("[%a_][%w_]*") do plist[#plist + 1] = p end
-- If the RHS is a block tag whose head is a known block, this is a
-- PARAMETERIZED BLOCK ALIAS: #param placeholders are substituted into
-- the block options at call time, and the call-site body becomes the
-- block content (so it may itself contain sub-blocks). The block
-- keyword may appear anywhere among the words.
local barhs = rhs:match("^%s*<(.-)>%s*$")
local bblock, bopts = nil, {}
if barhs then
for w in barhs:gmatch("%S+") do
if not bblock and sl._blocks[w] then
bblock = w
else
bopts[#bopts + 1] = w
end
end
end
if bblock then
BLOCKALIAS[name] = {block = bblock,
opts = table.concat(bopts, " "),
params = plist}
else
MACRO[name] = {params = plist, body = rhs}
end
else
local an, arhs = line:match("^%s*let%s+([%a_][%w_]*)%s*=%s*<(.-)>%s*$")
if an then
warn_if_shadows(an, lineno)
-- Find a block keyword ANYWHERE in the definition (not only as the
-- first word), so `let s = ` is recognised
-- as a block alias just like ``. The other
-- words become the alias's default options.
local blockname, opts = nil, {}
for w in arhs:gmatch("%S+") do
if not blockname and sl._blocks[w] then
blockname = w
else
opts[#opts + 1] = w
end
end
if blockname then
BLOCKALIAS[an] = {block = blockname,
opts = table.concat(opts, " "),
params = {}}
-- Also register it as a style alias so the attribute form
-- Title (no body braces) works too: it expands to the same
-- words and resolves the block keyword as a heading attribute.
local words = {}
for w in arhs:gmatch("%S+") do words[#words + 1] = w end
ALIAS[an] = STYLE.resolve_styles(words, ALIAS)
else
local words = {}
for w in arhs:gmatch("%S+") do words[#words + 1] = w end
ALIAS[an] = STYLE.resolve_styles(words, ALIAS)
end
else
local vn, vexpr = line:match("^%s*let%s+([%a_][%w_]*)%s*=%s*(.+)$")
if vn then
body_lines[#body_lines + 1] = {var = vn, expr = vexpr, lineno = lineno}
else
body_lines[#body_lines + 1] = {text = line, lineno = lineno}
end
end
end
::continue::
end
process_lines(code, body_lines)
code[#code + 1] = "return table.concat(_parts)\n"
return table.concat(code)
end
-- Collapse redundant paragraph breaks. A block whose content already ends a
-- paragraph (e.g. an alignment wrapper closing with "\par}") is then followed
-- by the line's own "\par", producing "\par} \par" -- two breaks in a row,
-- i.e. an unwanted blank line under headings or between blocks. Whenever two
-- \par are separated only by spaces and closing braces, the second is
-- redundant: keep the braces, drop the extra \par.
local function collapse_par(s)
local prev
repeat
prev = s
-- "\par \par" -> "\par "
s = s:gsub("(\\par[%s}]*)\\par", "%1")
until s == prev
return s
end
-- ---------------------------------------------------------------------
-- Untrusted (sandbox) mode
--
-- sl evaluates `let x = expr`, `#{expr}` and for/if/while conditions as Lua
-- at compile time. With sl.config.untrusted = true, that Lua runs in a
-- restricted environment that exposes only pure, side-effect-free names:
-- the maths the document language needs, and nothing that can touch the
-- filesystem, the OS, the package system, or the metatable machinery.
--
-- SCOPE AND LIMITS. This hardens the *sl expression layer* only. It does
-- NOT sandbox LuaLaTeX as a whole: a hostile .tex can still call
-- \directlua, \write18 (with shell-escape), \input, etc., entirely outside
-- sl. So `untrusted` is meaningful when the sl BODY comes from a
-- semi-trusted source (a form field, an exercise database, an injected .sl
-- fragment) while the surrounding .tex is yours. To compile a whole .tex
-- you do not trust, rely on the engine instead: lualatex WITHOUT
-- --shell-escape, ideally inside a container. The README states this plainly.
local SANDBOX_ALLOW = {
-- pure value libraries (no os/io/package/debug/require/load)
math = true, string = true, table = true,
-- safe base functions
type = true, tostring = true, tonumber = true, ipairs = true,
pairs = true, next = true, select = true, error = true,
assert = true, unpack = (table and table.unpack) and "table.unpack" or true,
}
-- Builds a fresh restricted _ENV. Library tables are passed through by
-- reference (math/table are pure), but `string` is wrapped: a few of its
-- functions (rep, format with a width field) can allocate gigabytes in a
-- single C call, which the instruction-count hook cannot interrupt because
-- it counts Lua steps, not work inside C functions. So string.rep is capped
-- and string.format is shadowed by a length-checking wrapper. The dangerous
-- globals are simply absent, so `os.execute(...)` raises "attempt to index a
-- nil value (global 'os')" rather than running.
local SANDBOX_STR_CAP = 100000 -- max characters any single string op may emit
local function safe_string()
local s = {}
for k, v in pairs(string) do s[k] = v end
s.rep = function(str, n, sep)
n = tonumber(n) or 0
local unit = #tostring(str) + (sep and #tostring(sep) or 0)
if n * unit > SANDBOX_STR_CAP then
error("scholatex: string.rep result too large in untrusted mode (limit "
.. SANDBOX_STR_CAP .. " characters)", 0)
end
return string.rep(str, n, sep)
end
s.format = function(fmt, ...)
local out = string.format(fmt, ...)
if #out > SANDBOX_STR_CAP then
error("scholatex: string.format result too large in untrusted mode (limit "
.. SANDBOX_STR_CAP .. " characters)", 0)
end
return out
end
return s
end
local function make_sandbox_env()
local env = {}
for name in pairs(SANDBOX_ALLOW) do
if name == "unpack" then
env.unpack = table and table.unpack
elseif name == "string" then
env.string = safe_string()
else
env[name] = _G[name]
end
end
-- math.random is reproducible-safe but pulls in global state; harmless.
-- Deliberately omit: os, io, package, require, load, loadstring, dofile,
-- loadfile, debug, collectgarbage, setmetatable, getmetatable, rawget,
-- rawset, rawequal, rawlen, _G, _ENV, print, newproxy.
return env
end
-- Runs a compiled chunk under an instruction-count ceiling, so a hostile
-- `while true do end` (reachable via a for/while condition or a `let`) does
-- not hang the compile. Uses debug.sethook on a coroutine; debug itself is
-- never exposed to the sandboxed code.
local SANDBOX_MAX_STEPS = 2e7
local function run_limited(chunk)
local co = coroutine.create(chunk)
local steps = 0
debug.sethook(co, function()
steps = steps + 1
if steps > SANDBOX_MAX_STEPS / 1e5 then
error("scholatex: untrusted document exceeded the instruction limit "
.. "(possible runaway loop); aborted", 0)
end
end, "", 1e5)
local ok, res = coroutine.resume(co)
debug.sethook(co)
if not ok then error(res, 0) end
return res
end
function sl.transpile(src)
ALIAS, MACRO, BLOCKALIAS = {}, {}, {}
sl._line = nil
-- Drive the literal-decimal separator in maths from the language option,
-- so $3.5$ and $#x$ (x = 3.5) render identically: "3{,}5" in fr, "3.5"
-- in en. Done here, once, before any $...$ span is transpiled.
local lang = (sl.config and sl.config.lang) or "fr"
MATH.decsep = (lang == "en") and "." or "{,}"
-- build_lua raises attribute/grammar errors synchronously; sl._line holds
-- the source line being processed, so we can point the author at it.
local okb, lua_code = pcall(build_lua, src)
if not okb then
local msg = tostring(lua_code):gsub("^.-:%d+: ", "") -- strip Lua position
if sl._line then
error("scholatex: line " .. sl._line .. ": " .. msg:gsub("^sl: ", ""), 0)
end
error(msg, 0)
end
local untrusted = sl.config and sl.config.untrusted
local chunk, err
if untrusted then
-- "t" forbids precompiled bytecode (a known sandbox-escape vector);
-- the 4th arg installs the restricted environment in place of _ENV.
chunk, err = load(lua_code, "=sl-body", "t", make_sandbox_env())
else
chunk, err = load(lua_code, "=sl-body")
end
if not chunk then
error("scholatex: transpilation error\n" .. err)
end
local ok, result
if untrusted then
ok, result = pcall(run_limited, chunk)
else
ok, result = pcall(chunk)
end
if not ok then
local msg = tostring(result)
-- A sandbox abort already carries a full "scholatex: ..." message; pass it
-- through verbatim instead of nesting it under "execution error". This
-- covers the instruction-limit ceiling and the string-size caps.
if untrusted and (msg:find("instruction limit", 1, true)
or msg:find("result too large", 1, true)) then
error("scholatex: " .. (msg:match("scholatex: (.*)$") or msg), 0)
end
-- In untrusted mode, "attempt to index a nil value (global 'os')" and
-- similar are the sandbox doing its job: rephrase them so the author
-- sees a security explanation, not a cryptic nil error.
if untrusted then
local blocked = msg:match("nil value %(global '([%a_][%w_]*)'%)")
or msg:match("call a nil value %(global '([%a_][%w_]*)'%)")
if blocked then
error("scholatex: '" .. blocked .. "' is not available in untrusted mode "
.. "(only pure maths and string/table helpers are permitted)", 0)
end
end
error("scholatex: execution error\n" .. msg, 0)
end
return collapse_par(result)
end
local function print_par_lines(out)
out = out:gsub("\n", " ")
local lines = {}
for seg in (out .. "\\par"):gmatch("(.-)\\par") do
lines[#lines + 1] = seg
lines[#lines + 1] = "\\par"
end
tex.print(lines)
end
sl._buf = {}
function sl_reset() sl._buf = {} end
function sl_addline(s) sl._buf[#sl._buf + 1] = s end
function sl_flush()
print_par_lines(sl.transpile(table.concat(sl._buf, "\n")))
end
function sl.inject(body)
print_par_lines(sl.transpile(body))
end
function sl.respace(macro)
local v = token.get_macro(macro)
if not v then return end
v = v:gsub("(%l)(%u)", "%1 %2"):gsub("(%u)(%u%l)", "%1 %2")
token.set_macro(macro, v)
end
sl._mathlite = MATH.mathlite
sl.use("scholatex-box")
sl.use("scholatex-table")
sl.use("scholatex-img")
sl.use("scholatex-section")
sl.use("scholatex-grid")
sl.use("scholatex-list")
sl.use("scholatex-matrix")
sl.use("scholatex-toc")
return sl