CTAN Comprehensive TeX Archive Network

Directory macros/luatex/latex/scholatex

README.md

scholatex

Print-ready teaching worksheets, without writing . scholatex is a small markup language for producing handouts, exercise sheets and short assessments. It is not a general-purpose replacement for — it is a focused, consistent little language for the documents a teacher actually makes: a few framed exercises, a table of results, some simple formulas, an image or two, laid out cleanly on a page meant to be printed.

It compiles to Lua, so the output has full typesetting quality, but the syntax is meant to be read and edited in minutes by someone who does not know . No \begin{tabular}{|c|c|}, no counting ampersands, no remembering which package draws a coloured box — one tag syntax covers text, tables, images, simple maths, framed boxes, lists and full-page named-area layouts, the building blocks of a worksheet.

It is built for the people usually loses: teachers preparing classroom materials, anyone who wants a clean printable sheet without climbing the learning curve. The full power of stays one escape hatch away — any raw command still works — but for everyday teaching documents you rarely need it.

You write a .tex file with a tiny, readable syntax between \begin{document} and \end{document}. At compile time the scholatex class reads that body, transpiles it to , and typesets it. Nothing to install beyond a Lua distribution.

% !TeX program = lualatex
\documentclass[margins={20,15,20,15}, size=12, imgdir=IMG]{scholatex}
\begin{document}

<navy b 18pt c>My first sl document

This is a normal paragraph. <b>{Bold}, <i>{italic}, <red>{red}.

<box line:Navy fill:AliceBlue radius:3 title:{A framed note}>{
Boxes, tables, images and maths all use the same tag syntax.
}

\end{document}

Compare a term report — three lines of scholatex against the dozen lines of tabular, \multicolumn, \rowcolor and \hline they replace:

<table [mc, ml, mc, mc] borders header fill:AliceBlue line:Navy headerfill:Navy headertext:White>{
<colspan:4 mc>{Term report}
Day | Subject | Mark | Coef.
<rowspan:2 mc>{Monday} | Maths | 15 | 4
. | French | 12 | 3
}

The one rule

Everything is a tag: <attributes> followed either by {content} (inline) or by a line that ends in { (a block). Attribute words follow a single case convention:

Form Meaning Examples
lowercase a short keyword or base colour b, i, c, red, 2tab
CamelCase an extended CSS colour (147 of them) SteelBlue, Crimson
UPPERCASE a font name DEJAVU SANS

The order of words inside a tag never matters — emission is always normalised (page break, then vertical skips, then alignment, then tabs, then styles).


Class options

Set in \documentclass[...]{scholatex}:

Option Default Meaning
margins 20 N (all sides) or {top,right,bottom,left} in mm
font Latin Modern Roman main text font
mathfont Latin Modern Math math font
size 11 base font size in pt
imgdir img folder(s) searched for bare image names; accepts a comma-separated list, e.g. {IMG, IMAGES/PNG}
tabwidth 8 width of one tab, in mm (a large Seyès square)
lineheight 8 height of one skipped line, in mm
scriptscale 100 scale (%) of up/down scripts
padding 2 inner padding (mm) between a box/grid-area frame and its content; a box may override it locally with sep:N
lang fr decimal separator convention: fr (comma, the world-wide majority — all of continental Europe, Latin America, francophone Africa…) or en (point, used in anglophone countries, China, Japan, Mexico…). Affects both literal decimals typed in maths ($3.5$) and interpolated ones (#x), so the two always match.
untrusted false when true, runs the document's Lua (let, #{…}, loop conditions) in a restricted sandbox: no os/io/require/load, a runaway-loop ceiling, and capped string.rep/format. Hardens the scholatex layer only — see Security.

Headings carry no extra vertical space of their own — only the normal interline spacing separates them from surrounding text, identically at every level. They have no built-in colour either: to style a heading (colour, weight, size, or a line skip before it), fold a heading keyword into an alias and use it, e.g. let h = <line navy b section> then <h>My title for a numbered section, preceded by a blank line, in navy bold.


Text attributes

Inline stylesb bold, i italic, u underline, emph, tt typewriter, sf sans-serif, sc small caps.

Colours — short keywords (red, blue, green, navy, orange, purple, teal, brown, gray, pink, …) or any of the 147 CSS colours in CamelCase (Tomato, SteelBlue, ForestGreen, …).

Fonts and sizes — a font name in CAPITALS (<DEJAVU SANS>{…}); sizes as Npt or Npx (<14pt>{…}).

Alignmentl left, c centre, r right, j justified (the same letters as table columns).

Tabs and skips (the number is always a prefix): Ntab indents the first line by N tabs; vertical skips follow singular / plural agreement: line or 1line skips one line, 2lines, 3lines, … skip several. Bare tab = 1tab.

ScriptsupN raises text by N mm (superscript-like), downN lowers it (subscript-like): x<up4>{2}, H<down2>{2}O.

Page breaknextpage.

Section headingssection, subsection, subsubsection give the three heading levels. numbers them automatically and resets the sub-counters on its own, so you never write a number by hand:

<section>First topic
<subsection>A detail
<subsubsection>A finer point

renders as 1 First topic, 1.1 A detail, 1.1.1 A finer point.

A heading can also be written as a block carrying a title: option, with its body in braces. Layout like tab goes on the body paragraphs (a heading takes only colour/style words):

<subsection title:{A heading}>{
<tab>This body paragraph is indented on its first line.
}

The lightweight form <section>Title (heading only, no body braces) keeps working unchanged for everyday use.

A table of contents is printed with <tableofcontents>; it lists every heading with its number and page. Give it a title in braces — it is centred, 's own default heading is suppressed (so nothing is duplicated), and the document continues on a fresh page after the contents:

<tableofcontents>{Table of contents}

Combine anything in one tag:

<np 2lines 2tab j navy b>{A new page, a two-line skip, a two-tab indent,
justified, navy, bold — all before the first letter of the text.}

Aliases and macros — the factoring tool

This is what makes scholatex scale. Define a style once, at the top of the document, then name it everywhere instead of repeating its attributes. One edit at the definition restyles the whole document.

let title = <navy b 18pt c>          % style alias
let h1    = <navy b section>         % a heading style, reusable
let p     = <tab>                    % a standard indented paragraph
<title>My heading
<h1>First topic
<p>{ A paragraph, indented and justified, named not described. }

let n = 7                            % value, usable in #{...}
Seven squared is #{n*n}.

let greet{name} = Hello #name!       % text macro with parameters
<...>{greet{World}}

Change let h1 = <navy b section> to <ForestGreen b section> once and every first-level heading follows — the single point of control that keeps a long worksheet consistent. (A justified paragraph is the default, so let p = <tab> and let p = <tab j> are equivalent: j only restates what already does.) The same idea applies to whole components further down — see Block aliases.


Tables

Columns are declared in brackets, one two-letter placement code per column — there is no single-letter form. The first letter is vertical (t top, m middle, b bottom), the second horizontal (l, c, r). So mc is middle-centre, br is bottom-right, tl top-left. This is the only placement syntax scholatex uses, in tables as in boxes and grids.

Columns share the page equally; N: before the code fixes a width in mm ([40:tl, 30:mc]). Cells are separated by |, rows by line breaks, and \\ breaks a line inside a cell.

<table [mc, tl, br] borders header>{
Figure | Formula | Value
<img 25>{cat.png} | $1/2 + 1/3$ | $5/6$
}

This places each column independently: the figure centred in its cell, the formula top-left, the value bottom-right. The vertical part is what lets a short formula sit at the middle (or top, or bottom) of a row made tall by an image, instead of always dropping to the baseline. When every cell in a row is the same height, the vertical letter simply has nothing to redistribute, so it shows only once a row holds something taller than its neighbours.

borders draws rules; header bolds the first row. gap:N sets the horizontal spacing between columns, in mm.

A cell may span several columns with colspan:N, or several rows with rowspan:N (N ≥ 2 in both cases). For a rowspan, each cell it covers on the lines below is marked with a lone ., exactly like an empty cell in a grid template. A spanning cell carries its own two-letter code (mc below) to place its merged content, overriding the columns it covers.

<table [mc, ml, mc, mc, ml] borders header gap:3>{
<colspan:5 mc>{Term report}
Day | Subject | Mark | Coef. | Remark
<rowspan:2 mc>{Monday} | Maths | 15 | 4 | Good
. | French | 12 | 3 | Fair
}

Each row must cover exactly the declared number of columns once spans and . markers are counted, or scholatex reports the mismatch with the source line.

Colour options act inside the table, on its cells and rules — no wrapping box. fill: colours every body cell, line: colours the rules, text: sets the text colour, and headerfill: / headertext: style the header row:

<table [mc, ml, mc] borders header gap:3 fill:AliceBlue line:Navy headerfill:Navy headertext:White>{
Day | Subject | Mark
Monday | Maths | 15
}

These are not box options: a table has no radius:, title: or outer frame of its own. To frame a table or round its corners, wrap it in a <box line:... radius:...>{ … }. With no colour option, the table renders exactly as before.

Vertical placement (m, b) needs a column that has a height to align within — an auto-width column or a fixed-width N: column. The vertical letter also anchors the content of rowspan cells in that column. A per-cell l/c/r tag inside a spanning cell still wins over the column's own setting.


Images

<img>{chat.png}        % fills the available width (the cell or line)
<img 25>{chat.png}     % 25 mm wide
<img 25x15>{chat.png}  % fits a 25×15 mm box, ratio preserved

With no size, the image is scaled to the full width available to it — the column of a table cell, or the text width in a paragraph. Give a width in mm to fix the size instead.

A bare name is searched in each folder of imgdir in turn, then at the project root. imgdir accepts several folders as a comma-separated list, so images may be spread across directories and still be referenced by name alone:

\documentclass[imgdir={IMG, IMAGES/PNG}]{scholatex}

An explicit path always works, with or without ./ (<img 20>{IMG/PNG/chat.png}).


Maths

Wrap maths in $…$. A small mini-language keeps it light:

You write You get
* ×
+- ±
<= >= != ≤ ≥ ≠
a/b fraction (chained a/b/c reads as (a/b)/c)
x^2 x_i power / index
sqrt(2) √2
sum(i=1, n) ∑ with bounds
abs(x) x
norm(v) ‖v‖
vec(AB) →AB (over-arrow vector)
lim(x->0) limit, -> becomes the arrow
pi, alpha, … Greek letters
inf

The helpers nest, so the secondary-school staples come for free: norm(vec(AB)) is the norm of a vector, vec(AB) + vec(BC) = vec(AC) is Chasles' relation, abs(x - lim(x->0) f(x)) reads in one line.

Inject a computed value with #{expr} (or #name), including inside maths: $#k^2$. Decimal numbers follow the lang option: with the default lang=fr a literal $3.5$ and an interpolated #x (x = 3.5) both render as 3,5; with lang=en both stay 3.5. The two are always consistent — the same separator is applied to typed and computed numbers alike.

Maths blocks

Matrices and systems are blocks: one line is one row, and inside a matrix ; separates the entries. Every cell still goes through the mini-language, so 2x+1 or 1/2 work in a cell.

<matrix>{
1 ; 2 ; 3
4 ; 5 ; 6
}

<matrix> draws parentheses, <det> the vertical bars of a determinant, <bmatrix> square brackets. A single | inside a row draws the separation bar of an augmented matrix, at the column where you type it (the same column on every row); it is allowed on matrix and bmatrix, never on det:

<bmatrix>{
2 ; 1 | 7
1 ; -1 | 1
}

A <system> stacks equations under a brace and aligns them on the first relational operator, so equalities and inequalities mix freely — one equation per line, no separator:

<system>{
2x + 3y = 7
x - y = 1
}

Every row of a matrix must hold the same number of cells, and a bar must sit at the same column on every row, or scholatex reports the mismatch with the source line.


Lists

<list:STYLE>{ … } makes a list; the style is required and comes right after the name, like a block. One item per non-empty line — no item tag. A <list:…> written under an item becomes its sub-list, nested as deep as you like, each level with its own style.

<list:disc>{
Fruits
<list:circle>{
pommes
poires
}
Légumes
<list:square>{
carottes
navets
}
}

Styles — bullets: none disc circle square; numbered: decimal alpha ALPHA roman ROMAN (the case of the keyword is the case of the letters/numerals); checkboxes: check.

<list:ROMAN>{
chapitre 1
chapitre 2
}

<list:check>{
Apporter sa règle
Coller la fiche
}

Text attributes follow the style on the same tag and wrap the whole list: <list:ROMAN TIMES NEW ROMAN 12pt i>{ … } sets the items in Times 12 pt italics. Writing <list> without a style is an error.


Boxes

<box line:Crimson fill:MistyRose radius:4 title:{A note}>{
Content here.
}

Options: line: frame colour, fill: background, text: text colour, radius:N rounded corners (mm), width:N or width:N%, boxrule:N, boxsep:N, break:yes (allow page breaks), title:{…}, titlefill:, titletext:. A line containing only --- splits a box into an upper and a lower region.

<row gap:N>{ … } lays its child boxes side by side, with equal widths and equalised heights. A row accepts only <box> children, written either on one line (<box line:Navy>{ short text }) or in multi-line block form (<box line:Navy>{}) — use the multi-line form when a child itself contains blocks, tables, or several paragraphs.

A box also takes a two-letter placement code (tl tc tr ml mc mr bl bcbr, default tl) that positions its content: first letter vertical (t/m/b), second horizontal (l/c/r). The vertical part needs a height: to have room to act — without one the box hugs its content and tc, mc, bc look the same. In a row, each child box carries its own code, so columns can be aligned independently (<box bc height:30>{…}).


Grid (named-area layout)

For full-page layouts — a worksheet header with a logo, a title bar, info fields, and a body — <grid> borrows CSS Grid's named-area idea. A template:[ … ] of quoted rows draws the layout; each word names the cell at that position. A name repeated horizontally spans columns; repeated vertically it spans rows. A dot . is an empty cell. Each name must form a solid rectangle.

<grid template:[
  "title  title  logo"
  "intro  info   logo"
  "body   body   body"
] gap:4>{
  <area title>{ <red b 16pt>Maths assessment }
  <area logo >{
    <img>{blason.png}
  }
  <area intro>{ Instructions: no calculator. }
  <area info >{ Name: \\ First name: }
  <area body >{
    <s1>Exercise 1
    Solve the equation...
  }
}

Here title spans the first two columns of the top row, logo spans the two right-hand rows, and body spans the full width. Columns share the text width equally; gap:N sets the spacing between cells in mm. Each <area NAME>{ … } supplies the content for one name, in any order; an area may hold plain text, inline styles, or — written in multi-line block form — boxes, headings, tables, and other grids.

An area can be framed like a box by giving it the same options as <box>line:, fill:, radius:, title: — right after its name:

<area body line:Navy fill:AliceBlue radius:2 title:{Exercise 1}>{
  Solve the equation...
}

An area with no options stays invisible (a bare cell); add options only to the zones you want framed, so the same grid serves both a clean final worksheet and a structure you can see while designing.

The grid itself takes width: and height:. width: is a percentage of the text width (width:90%) or a millimetre value (width:120); the grid is then that wide (default: full width). height: is a millimetre value (height:80) fixing the total height; when given, the grid also reserves that much vertical space, so if it would not fit at the bottom of a page it moves to the next page as a whole instead of being split. Without height: the grid takes a natural height from its content. A grid used as a page-top worksheet header needs neither; height: helps when a grid sits in the middle of flowing text.

An area also takes a two-letter placement code that positions its whole content within the cell: the first letter is vertical (t top, m middle, b bottom), the second horizontal (l left, c centre, r right) — tl tc tr ml mc mr bl bc br. The default is tl. A code on the <grid> itself sets the default for every area; a code on an <area> overrides it. The vertical part only shows when the cell is taller than its content (give the grid a height:, or the area sits in a tall row).

The text-alignment words l c r j are distinct: they align the text inside the content, and combine freely with a placement code.

<grid template:[...] mc>{                  every area centred by default
<area logo>{ <img>{badge.png} }            inherits mc — centred badge
<area body bl j>{ ... }                    pinned bottom-left, text justified
}

An image with no size (<img>{file.png}) is scaled down to fit the width of its area but never enlarged; give a size (<img 30>{file.png}, <img 40x25>{...}) to fix it in millimetres.


Block aliases (the factoring tool)

Define a reusable component once; #param placeholders are filled at the call site, and the call-site body becomes the block content — so it may contain sub-blocks of its own.

let card{title, frame} = <box title:{#title} line:#frame radius:2>

<card First, Crimson>{
Called with two arguments. No box options to repeat.
}
<card Second, Navy>{
Same component, different look.
}

The body can nest blocks freely:

let panel{title} = <box title:{#title} fill:AliceBlue line:SteelBlue>

<panel Worked example>{
Intro text.
<row gap:4>{
<box line:Crimson title:{Given}>{ A triangle, base 6, height 4. }
<box line:Green  title:{Find}>{ Area: $6 * 4 / 2 = 12$. }
}
}

Control flow

for n in 1..3 {            % numeric range
<c navy b>Sheet #n
}

for f in [chat.png, chien.png, fig1.png] {   % explicit list
<img 16>{#f}
}

if score >= 10 {
<green>Passed.
} else {
<red>Try again.
}

while cond { … }

Loops and conditions work in the document body, inside boxes, and inside table bodies. The loop variable interpolates everywhere via #.


Escapes

Literal special characters: \< \> \{ \} \#. The characters _ & % ~ are escaped automatically. A double backslash \\ is a line break (in text and in table cells). A lone $ with no closing $ on the same paragraph is treated as a literal dollar sign (with a warning), so it no longer swallows the rest of the line into a maths span.

A line whose first non-space character is % is a comment and is dropped (the convention). A % anywhere else on the line is ordinary text and is escaped for you. To print a line that must begin with a percent sign, write \% at its start (\% de réussite): the backslash is removed and the rest, starting with %, is typeset — so a leading % never silently deletes a line you meant to keep.

A bare # that is not followed by a name or {…} is a literal # (#3, C# majeur): only #name and #{expr} interpolate. An interpolation whose value is missing renders as empty rather than the word nil.


Security

scholatex evaluates let name = expr, #{expr} and the conditions of for/if/while as Lua at compile time, so by default a document can run arbitrary code — exactly like \directlua.

The untrusted option

Setting untrusted=true in \documentclass[...]{scholatex} runs that Lua in a restricted environment: only pure, side-effect-free names are visible — the maths the document language needs plus string/table helpers — while os, io, package, require, load, debug, setmetatable and the other escape vectors are simply absent. A blocked access stops the compile with a clear message (scholatex: 'os' is not available in untrusted mode). A runaway loop is aborted by an instruction-count ceiling, and string.rep / string.format are capped so a single call cannot allocate gigabytes.

\documentclass[untrusted=true]{scholatex}
% now an exercise pulled from an untrusted source can still do maths…
let note = 14.5
Moyenne : #note / 20.

% …but cannot touch the system:
#{os.execute("rm -rf ~")}     % -> scholatex: 'os' is not available in untrusted mode

What untrusted does and does not protect

untrusted hardens the scholatex expression layer only. It does not sandbox Lua as a whole: a hostile .tex can still call \directlua, \write18 (with shell-escape), \input, and so on, entirely outside scholatex.

So the option is meaningful when the scholatex body comes from a semi-trusted source — a form field, an exercise database, a generated or received .sl fragment you inject — while the surrounding .tex (the \documentclass line and anything around the body) is your own.

To compile a whole .tex you do not trust, do not rely on this flag alone. Use the engine's own protections: run lualatex without --shell-escape, and ideally inside a container or a restricted user account. untrusted=true is a useful extra layer on top of that, not a replacement for it.

Other caveats

A multi-paragraph or block-containing <box> opens with the { alone on its line (<box ...>{ then the body on the following lines, then a closing }). A box written entirely on one line — <box ...>{ body } — is read as inline text in the document body, but is accepted as a child of <row>, where the one-line form is the convenient way to place short boxes side by side.

The body is extracted between the first \begin{document} and the first \end{document}; an \end{document} appearing literally inside the body (e.g. in a code sample) would cut it short.


Examples

The examples/ folder contains three self-contained, fully commented documents that together exercise every feature:

File Covers
01-text-style.tex the case rule, styles, colours, fonts, sizes, alignment, tabs, skips, scripts; factoring styles into aliases; a table of contents from the heading keywords
02-containers.tex tables, boxes and the named-area grid, each built up from its simplest form to a full worksheet header
03-math.tex the inline mini-language, the helpers (abs norm vec lim), and the matrix / determinant / augmented-matrix / system blocks

Compile any of them with lualatex <file>.tex from the examples/ folder. The image folders IMG/ and IMAGES/PNG/ ship alongside them; 02-containers.tex uses imgdir={IMG, IMAGES/PNG} to show that bare image names are resolved across several directories.


Project layout

scholatex.cls            LaTeX class: options, packages, reads & injects the body
scholatex.lua            transpiler core: tags, text, control flow, aliases
scholatex-style.lua      attribute resolution (colours, styles, sizes, alignment…)
scholatex-math.lua       the $…$ math mini-language
scholatex-util.lua       parsing primitives (groups, brace balance, comma split)
scholatex-table.lua      the <table> block
scholatex-img.lua        the <img> tag
scholatex-box.lua        the <box> and <row> blocks
scholatex-grid.lua       the <grid> named-area layout block
scholatex-section.lua    the <section>/<subsection>/<subsubsection> container blocks
scholatex-list.lua       the <list:STYLE> block
scholatex-matrix.lua     the <matrix>/<det>/<bmatrix> and <system> maths blocks
scholatex-toc.lua        the <tableofcontents> table-of-contents tag
examples/         three commented showcase documents

New tags and blocks register themselves via scholatex.register_tag / scholatex.register_block; a name clash now raises an error rather than silently overwriting, so modules stay independent.


Diagnostics

Errors point at the source line, e.g. scholatex: line 12: unknown tag attribute:'xyz'. Defining an alias whose name is a built-in (let section = …,let tab = …) prints a warning: the built-in always wins, so the alias would be silently dead — pick a different name (let s1 = <line navy section> and use <s1>).


License

Copyright © 2026 Gérard Dubard.

scholatex 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. See the LICENSE file for the full text.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

Download the contents of this package in one zip archive (213.6k).

scholatex – A simple tag-based language for creating school worksheets and handouts

This package provides a simple tag-based language for creating school worksheets, exercise sheets and handouts with Lua.

It is not a general-purpose replacement for , but a small, consistent language for the documents a teacher actually makes: framed exercises, a table of results, simple formulas, images, laid out cleanly on a page meant to be printed and handed out. A single tag syntax covers text, tables, images, simple maths, framed boxes, lists and full-page named-area layouts. Repeated styling is factored into reusable aliases, and basic control flow (loops, conditionals, interpolation) is available. It is meant to be read and edited in minutes by someone who does not know . Requires a Lua engine; no extra toolchain.

Packagescholatex
Version1.0 2026-06-18
LicensesGNU General Public License, version 3 or newer
Copyright2026 Gérard Dubard
MaintainerGérard Dubard
TopicsTeaching
Simplified
Struc mkup
...
Guest Book Sitemap Contact Contact Author