-- 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