-- ====================================================================
-- numodel-coach.lua --- write numodel models as CMA Coach 7 files
-- numodel-coach.lua v0.10.0 2026/10/08
-- ====================================================================
--
-- Copyright (C) 2026 Paul Zuurbier
--
-- This work may be distributed and/or modified under the conditions
-- of the LaTeX Project Public License, either version 1.3c of this
-- license or (at your option) any later version. The latest version
-- of this license is in https://www.latex-project.org/lppl.txt
--
-- This work has the LPPL maintenance status 'maintained'.
-- The Current Maintainer of this work is Paul Zuurbier.
--
-- This work consists of the files numodel-coach.dtx,
-- numodel-coach.ins, the derived file numodel-coach.sty, and the
-- companion Lua files numodel-coach.lua and numodel-coach-template.lua.
-- ====================================================================
-- Turns a numodel model into a Coach 7 modelling activity (.cma7) in
-- text mode. The model text comes from numodel.plaintext (Coachtaal),
-- the model data from numodel.get_model; this file only knows the
-- Coach file format.
--
-- The format is not documented by CMA. What is known (reverse
-- engineered from files saved by Coach 7.0):
-- * a 16-byte header starting with "CMA ", then records;
-- * container: u64 length, 0e 00 00 00 00, u8 name length, name,
-- children (length counts the whole record);
-- * leaf: u32 length, u32 0x0b, u8 name length, u8 flag, u8 type,
-- name, value. Types: 0 byte, 2 int32, 3 80-bit extended,
-- 4 ANSI (cp1252) text, 6 UTF-16LE text. Every text is stored
-- twice: as name (ANSI) and name_uuuu (UTF-16). Flag 1 marks an
-- unnamed long text, preceded by four zero bytes;
-- * no checksums.
-- Model-relevant records:
-- Description/CanSwitchModelModes 1 = pupil may switch between the
-- graphical and the text view. The text view is derived from
-- the graphical model (ModelXML), so switching loses a text-only
-- model: numodel-coach switches it off by default.
-- GrModMain/Mode 1 = text view
-- ModelXML graphical model; its ,
-- and set the iteration count:
-- (stop - start)/step + 1
-- ModelBody, ModelInit model rules, initial values
-- VarList variables shown in table/graphs
-- ====================================================================
local C = {}
numodelcoach = C
-- The template is found through kpse inside LuaTeX; standalone texlua
-- (the unit tests) sets C.template itself.
local ok, template_file = pcall(function()
return kpse.find_file("numodel-coach-template.lua", "tex")
end)
C.template = ok and template_file and dofile(template_file) or nil
-- --- encoding ---------------------------------------------------------
-- UTF-8 -> UTF-16LE.
local function utf16le(s)
local out = {}
for _, cp in utf8.codes(s) do
if cp >= 0x10000 then
cp = cp - 0x10000
local hi, lo = 0xD800 + (cp >> 10), 0xDC00 + (cp & 0x3FF)
out[#out + 1] = string.pack(" cp1252 (Windows-1252), '?' for what it cannot hold -- the
-- same as Coach writes in its ANSI copies (Δt -> ?t).
local cp1252_high = {
[0x20AC] = 0x80, [0x201A] = 0x82, [0x0192] = 0x83, [0x201E] = 0x84,
[0x2026] = 0x85, [0x2020] = 0x86, [0x2021] = 0x87, [0x02C6] = 0x88,
[0x2030] = 0x89, [0x0160] = 0x8A, [0x2039] = 0x8B, [0x0152] = 0x8C,
[0x017D] = 0x8E, [0x2018] = 0x91, [0x2019] = 0x92, [0x201C] = 0x93,
[0x201D] = 0x94, [0x2022] = 0x95, [0x2013] = 0x96, [0x2014] = 0x97,
[0x02DC] = 0x98, [0x2122] = 0x99, [0x0161] = 0x9A, [0x203A] = 0x9B,
[0x0153] = 0x9C, [0x017E] = 0x9E, [0x0178] = 0x9F,
}
local function cp1252(s)
local out = {}
for _, cp in utf8.codes(s) do
if cp < 0x80 or (cp >= 0xA0 and cp <= 0xFF) then
out[#out + 1] = string.char(cp)
else
out[#out + 1] = string.char(cp1252_high[cp] or 0x3F)
end
end
return table.concat(out)
end
-- Number -> 80-bit x87 extended, little endian (Delphi "Extended").
local function ext80(x)
if x == 0 then return string.rep("\0", 10) end
local sign = x < 0 and 0x8000 or 0
local m, e = math.frexp(math.abs(x)) -- x = m * 2^e, 0.5 <= m < 1
local hi = math.floor(m * 2^32) -- top 32 mantissa bits
local lo = math.floor((m * 2^32 - hi) * 2^32)
return string.pack(""] = ">" }
local function esc(s) return (s:gsub("[&<>]", html_escape)) end
local text_symbols = {
ldots = "…", dots = "…", textellipsis = "…", cdot = "·",
times = "×", degree = "°", celsius = "°C", degreeCelsius = "°C",
pm = "±", approx = "≈", leq = "≤", leqslant = "≤", geq = "≥",
geqslant = "≥", neq = "≠", ne = "≠", to = "→", rightarrow = "→",
infty = "∞", textendash = "–", textemdash = "—", euro = "€",
percent = "%", quad = " ", qquad = " ", space = " ",
}
-- Relations in math, set with spaces around them.
local math_relations = { leq = "≤", leqslant = "≤", geq = "≥",
geqslant = "≥", neq = "≠", ne = "≠", approx = "≈", to = "→",
rightarrow = "→", propto = "∝" }
local ignored = { noindent = true, medskip = true, smallskip = true,
bigskip = true, centering = true, relax = true, ignorespaces = true,
displaystyle = true, left = true, right = true }
local wrap_tags = { textbf = "b", bfseries = nil, emph = "i", textit = "i",
textsl = "i", underline = "u", textsuperscript = "sup",
textsubscript = "sub" }
local plain_groups = { text = true, textrm = true, textnormal = true,
mbox = true, textsf = true, texttt = true, mathrm = true,
mathit = true, mathbf = true, operatorname = true, textup = true }
-- Read one argument at s[i]: a braced group (returns its content) or a
-- single token. Returns content and the index after it.
local function read_arg(s, i)
i = s:find("%S", i) or #s + 1
local c = s:sub(i, i)
if c == "{" then
local depth, j = 0, i
while j <= #s do
local d = s:sub(j, j)
if d == "\\" then j = j + 1
elseif d == "{" then depth = depth + 1
elseif d == "}" then
depth = depth - 1
if depth == 0 then return s:sub(i + 1, j - 1), j + 1 end
end
j = j + 1
end
return s:sub(i + 1), #s + 1
elseif c == "\\" then
local cs = s:match("^\\%a+", i) or s:sub(i, i + 1)
return cs, i + #cs
end
return c, i + 1
end
local function fmt_num(s, ctx)
s = s:gsub("%s", "")
return (s:gsub("(%d)%.(%d)", "%1" .. ctx.decimal .. "%2"))
end
-- Unit for display: m/s^2 -> m/s2.
local function unit_html(u)
local p = numodel.plain_unit(u)
return (esc(p):gsub("%^(%-?%d+)", "%1"))
end
local convert, convert_cmd
-- Math: letters italic, _ and ^ as sub/sup, \frac as a/b.
local function convert_math(s, ctx)
local out, i = {}, 1
local function emit(x) out[#out + 1] = x end
while i <= #s do
local c = s:sub(i, i)
if c:match("%a") then
local run = s:match("^%a+", i)
emit("" .. run .. ""); i = i + #run
elseif c == "_" or c == "^" then
local a; a, i = read_arg(s, i + 1)
local tag = c == "_" and "sub" or "sup"
emit("<" .. tag .. ">" .. convert_math(a, ctx) .. "" .. tag .. ">")
elseif c == "{" then
local a; a, i = read_arg(s, i)
emit(convert_math(a, ctx))
elseif c == "\\" then
local cs = s:match("^\\(%a+)", i)
if cs then
i = i + 1 + #cs
if plain_groups[cs] then
local a; a, i = read_arg(s, i)
emit(convert(a, ctx, false))
elseif cs == "frac" or cs == "tfrac" or cs == "dfrac" then
local a, b; a, i = read_arg(s, i); b, i = read_arg(s, i)
local A, B = convert_math(a, ctx), convert_math(b, ctx)
if a:find("[%+%-%s]") then A = "(" .. A .. ")" end
if b:find("[%+%-%s]") then B = "(" .. B .. ")" end
emit(A .. "/" .. B .. " ") -- 1/2 mv, not 1/2mv
elseif cs == "sqrt" then
local a; a, i = read_arg(s, i)
emit("√(" .. convert_math(a, ctx) .. ")")
elseif numodel.greek[cs] then emit(numodel.greek[cs])
elseif math_relations[cs] then
emit(" " .. math_relations[cs] .. " ")
elseif text_symbols[cs] then emit(text_symbols[cs])
elseif cs == "qty" or cs == "SI" or cs == "num"
or cs == "unit" or cs == "si" then
i = i - 1 - #cs
local a; a, i = convert_cmd(s, i, ctx)
emit(a)
elseif not ignored[cs] then
ctx.warn("instruction: unknown math command \\" .. cs)
end
else
local sym = s:sub(i + 1, i + 1)
i = i + 2
if sym == "," or sym == ";" or sym == ":" then emit(" ")
elseif sym ~= "!" then emit(esc(sym)) end
end
elseif c:match("%s") then
i = i + 1 -- TeX ignores spaces in math
elseif c == "=" or c == "<" or c == ">" then
emit(" " .. esc(c) .. " "); i = i + 1
elseif c == "+" or c == "-" then
-- Binary (spaced) after an operand, unary otherwise.
local sym = c == "-" and "−" or "+"
local prev = out[#out]
if prev and not prev:match("[%s(]$") then
emit(" " .. sym .. " ")
else
emit(sym)
end
i = i + 1
else
emit(esc(c)); i = i + 1
end
end
return (table.concat(out):gsub("", ""):gsub(" +", " "))
end
-- One control sequence at s[i] (the backslash). Returns HTML and the
-- index after the command and its arguments.
convert_cmd = function(s, i, ctx)
local cs = s:match("^\\(%a+)", i)
if not cs then
local sym = s:sub(i + 1, i + 1)
if sym == "\\" then return "
", i + 2 end
if sym == "," then return " ", i + 2 end
if sym == "-" or sym == "/" then return "", i + 2 end
return esc(sym), i + 2
end
i = i + 1 + #cs
if s:sub(i, i) == " " then i = i + 1 end -- detokenize space
local a
if wrap_tags[cs] then
a, i = read_arg(s, i)
local t = wrap_tags[cs]
return "<" .. t .. ">" .. convert(a, ctx, false) .. "" .. t .. ">", i
elseif plain_groups[cs] then
a, i = read_arg(s, i)
return convert(a, ctx, false), i
elseif cs == "qty" or cs == "SI" then
local b
a, i = read_arg(s, i); b, i = read_arg(s, i)
return fmt_num(a, ctx) .. " " .. unit_html(b), i
elseif cs == "num" then
a, i = read_arg(s, i)
return fmt_num(a, ctx), i
elseif cs == "unit" or cs == "si" then
a, i = read_arg(s, i)
return unit_html(a), i
elseif cs == "par" then return "\0P", i
elseif cs == "newline" or cs == "linebreak" then return "
", i
elseif cs == "item" then
if s:match("^%s*%[", i) then
local j = s:find("]", i, true) or i
i = j + 1
end
return "\0I", i
elseif cs == "begin" or cs == "end" then
a, i = read_arg(s, i)
local tag = (a == "itemize" and "ul") or (a == "enumerate" and "ol")
if not tag then
ctx.warn("instruction: environment " .. a .. " is not converted")
return "", i
end
if cs == "begin" then return "\0P<" .. tag .. ">\0L", i end
return "" .. tag .. ">\0P", i
elseif numodel.greek[cs] then return numodel.greek[cs], i
elseif text_symbols[cs] then return text_symbols[cs], i
elseif ignored[cs] then return "", i
end
ctx.warn("instruction: unknown command \\" .. cs .. "; its text is kept")
if s:match("^%s*{", i) then
a, i = read_arg(s, i)
return convert(a, ctx, false), i
end
return "", i
end
-- Text mode.
convert = function(s, ctx)
local out, i = {}, 1
local function emit(x) out[#out + 1] = x end
while i <= #s do
local c = s:sub(i, i)
if c == "\\" then
if s:sub(i, i + 1) == "\\(" then
local j = s:find("\\)", i + 2, true) or #s + 1
emit(convert_math(s:sub(i + 2, j - 1), ctx)); i = j + 2
else
local h; h, i = convert_cmd(s, i, ctx); emit(h)
end
elseif c == "$" then
local j = s:find("$", i + 1, true) or #s + 1
emit(convert_math(s:sub(i + 1, j - 1), ctx)); i = j + 1
elseif c == "{" then
local a; a, i = read_arg(s, i); emit(convert(a, ctx, false))
elseif c == "}" then i = i + 1
elseif c == "~" then emit(" "); i = i + 1
elseif s:sub(i, i + 2) == "---" then emit("—"); i = i + 3
elseif s:sub(i, i + 1) == "--" then emit("–"); i = i + 2
elseif s:sub(i, i + 1) == "``" then emit("“"); i = i + 2
elseif s:sub(i, i + 1) == "''" then emit("”"); i = i + 2
elseif c == "%" then
i = (s:find("\n", i, true) or #s) + 1 -- comment
else
emit(esc(c)); i = i + 1
end
end
return table.concat(out)
end
-- LaTeX source (detokenized) -> { html = ..., text = ... }. decimal is
-- the decimal mark for \num/\qty (default ",").
function C.instruction_html(src, decimal, warn)
local ctx = { decimal = decimal or ",", warn = warn or function() end }
local raw = convert(src, ctx)
-- \item markers: first one after / opens the item.
raw = raw:gsub("\0L%s*\0I", "- "):gsub("\0I", "
- ")
:gsub("\0L", "
- ")
local blocks = {}
for chunk in (raw .. "\0P"):gmatch("(.-)\0P") do
chunk = chunk:gsub("%s+", " "):gsub("^ ", ""):gsub(" $", "")
chunk = chunk:gsub("
- ", "
- "):gsub("
", "")
if chunk ~= "" then
if chunk:match("^<[uo]l>") then blocks[#blocks + 1] = chunk
else blocks[#blocks + 1] = "" .. chunk .. "
" end
end
end
local html = "" .. table.concat(blocks) .. ""
local text = html:gsub("- ", "- "):gsub("
", "\n")
:gsub("", "\n"):gsub("
", "\n"):gsub("<[^>]+>", "")
:gsub("<", "<"):gsub(">", ">"):gsub("&", "&")
return { html = html, text = (text:gsub("\n+$", "")) }
end
-- Instructions stored per model prefix by the coachinstruction
-- environment, used by the next \coachmodel for that prefix.
C.instructions = {}
function C.set_instruction(p, src)
C.instructions[p] = src
end
-- --- building an activity ---------------------------------------------
-- spec = {
-- body, init model rules / initial values (UTF-8 text),
-- variables = { { name, unit, min, max, decimals }, ... },
-- iterations number of iterations (>= 1),
-- allow_switch true: pupils may switch to the graphical view
-- instruction optional { html = ..., text = ... } for Coach's
-- instruction window (see instruction_html)
-- }
function C.build(spec)
if not C.template then
error("numodel-coach: cannot find numodel-coach-template.lua")
end
local tree = deep_copy(C.template)
local items = tree.items
local desc = find(items, "Description")[4]
find_leaf(desc, "CanSwitchModelModes")[5] =
string.char(spec.allow_switch and 1 or 0)
find_leaf(find(items, "GrModMain")[4], "Mode")[5] = "\1"
if spec.instruction then
find(items, "NewText\0Instructie")[4] = long_text(spec.instruction.text)
find(items, "HTMLText\0Instructie")[4] = long_text(spec.instruction.html)
end
find(items, "ModelBody")[4] = long_text(spec.body)
find(items, "ModelInit")[4] = long_text(spec.init)
local vl = { leaf("Number", 2, string.pack(" has no other role.
local xml_rec = find(items, "ModelXML")
local xml = xml_rec[4][1][5]:sub(5) -- ANSI copy, CRLF
local n = math.max(1, math.floor(spec.iterations or 101))
local function set(tag, val)
xml = xml:gsub("<" .. tag .. ">[^<]*" .. tag .. ">",
"<" .. tag .. ">" .. val .. "" .. tag .. ">", 1)
end
set("start", "0"); set("stop", tostring(n - 1)); set("step", "1")
xml_rec[4] = long_text(xml)
return C.serialise(tree)
end
-- --- from a numodel model ----------------------------------------------
-- Build the activity for numodel prefix p. Returns the file contents
-- and the list of warnings (things that could not be exported
-- exactly). opts.allow_switch as in build.
function C.from_model(p, opts)
opts = opts or {}
local g = numodel.get_model(p)
if not g then error("numodel-coach: unknown model prefix '" .. p .. "'") end
local r = numodel.plaintext(p, { dialect = "NL" })
local W = {}
for _, w in ipairs(r.warnings) do W[#W + 1] = w end
local vars = {}
for _, v in ipairs(g.vars) do
local unit, w = numodel.plain_unit(v.unit)
-- Units of variables with a start value were already checked
-- by plaintext (they appear as comments there).
if w and not v.has_start then W[#W + 1] = w end
-- Coach's axis range for the variable: the range of its
-- graph in the document (\diagrammodel), else Coach's 0..10.
vars[#vars + 1] = { name = r.names[v.name], unit = unit,
min = v.axis_min, max = v.axis_max }
end
local instruction
if C.instructions[p] then
instruction = C.instruction_html(C.instructions[p], ",",
function(w) W[#W + 1] = w end)
end
local data = C.build({
body = r.body, init = r.init, variables = vars,
iterations = g.maxiter, allow_switch = opts.allow_switch,
instruction = instruction,
})
return data, W
end
-- Write the activity for prefix p to path, creating the directory if
-- needed. Returns the list of warnings.
function C.write(p, path, opts)
local data, W = C.from_model(p, opts)
local dir = path:match("^(.*)/[^/]*$")
if dir and dir ~= "" and lfs and not lfs.isdir(dir) then
local acc = path:sub(1, 1) == "/" and "" or nil
for part in dir:gmatch("[^/]+") do
acc = acc and (acc .. "/" .. part) or part
if not lfs.isdir(acc) then lfs.mkdir(acc) end
end
end
local f, err = io.open(path, "wb")
if not f then error("numodel-coach: cannot write " .. path .. ": " .. tostring(err)) end
f:write(data)
f:close()
return W
end
-- Called by \coachmodel: write the file and hand every warning to
-- TeX as \numodelcoachwarn{prefix}{path}{text}. The text is passed
-- with catcode "other" (tex.sprint -2), so braces or backslashes in it
-- are harmless.
function C.tex_write(p, path, allow_switch)
if not numodel.get_model(p) then
tex.sprint("\\numodelcoachnomodel{")
tex.sprint(-2, p)
tex.sprint("}")
return
end
local W = C.write(p, path, { allow_switch = allow_switch })
for _, w in ipairs(W) do
tex.sprint("\\numodelcoachwarn{")
tex.sprint(-2, p)
tex.sprint("}{")
tex.sprint(-2, path)
tex.sprint("}{")
tex.sprint(-2, w)
tex.sprint("}")
end
tex.sprint("\\numodelcoachwritten{")
tex.sprint(-2, path)
tex.sprint("}")
end
-- Readable summary of a written file (tests, debugging): the parts
-- numodel-coach fills in.
function C.dump(data)
local tree = C.parse(data)
local items, out = tree.items, {}
local function utf16(s)
local cps, i = {}, 1
while i + 1 <= #s do
cps[#cps + 1] = string.unpack("([^<]*)<")
end
out[#out + 1] = "Instruction text:"
out[#out + 1] = utf16(find(items, "NewText\0Instructie")[4][2][5]:sub(5))
out[#out + 1] = "Instruction HTML:"
out[#out + 1] = utf16(find(items, "HTMLText\0Instructie")[4][2][5]:sub(5))
out[#out + 1] = "ModelBody:"
out[#out + 1] = utf16(find(items, "ModelBody")[4][2][5]:sub(5))
out[#out + 1] = "ModelInit:"
out[#out + 1] = utf16(find(items, "ModelInit")[4][2][5]:sub(5))
out[#out + 1] = "VarList:"
for _, it in ipairs(find(items, "VarList")[4]) do
local name, typ, v = it[2], it[4], it[5]
if not name:find("_uuuu$") then
local s
if typ == 6 then s = utf16(v)
elseif typ == 4 then s = v
elseif typ == 2 then s = tostring(string.unpack("