pplua User Guidepplua?pplua sits in the classic roff preprocessor
pipeline — right alongside tbl, eqn,
and pic. It reads your .roff source, finds
embedded Lua code, executes it, and emits clean groff output. The
philosophy is simple: anything tedious or repetitive in groff
can be scripted in Lua.
your_doc.roff → pplua → tbl → eqn → groff -ms -Tpdf → output.pdf
There are two ways to embed Lua:
| Syntax | Purpose |
|---|---|
.lua / .endlua block |
Multi-line Lua code that calls lroff.* to emit
groff |
\lua'expr' inline |
Single expression whose return value is inserted into the text |
mkdir build && cd build
cmake .. -DSOL2_INCLUDE_DIR=/path/to/sol2/include
make -j$(nproc)
sudo make install # installs 'pplua' to /usr/local/binpplua [options] [file ...]
-e CODE Execute Lua CODE before processing any input.
-l FILE Run a Lua preamble file (e.g., shared data).
-I PATH Add PATH to Lua's package.path.
-D NAME=VALUE Set a Lua global variable (string).
-n Suppress .lf line-number directives.
-V Print version and exit.
-h Print help and exit.
# Simple
pplua doc.roff | groff -ms -Tpdf > doc.pdf
# With tbl tables and eqn equations
pplua doc.roff | tbl | eqn | groff -ms -Tpdf > doc.pdf
# With a shared data file and a debug flag
pplua -l data.lua -D DEBUG=1 report.roff | groff -ms -Tpdf > report.pdf
# From stdin
cat doc.roff | pplua | groff -ms -Tpdf > doc.pdflroff API at a
GlanceEvery Lua block has access to the lroff table. Here is
the complete API grouped by category:
| Function | Description |
|---|---|
lroff.emit(text) |
Write raw text (no trailing newline) |
lroff.emitln(text) |
Write text + newline |
lroff.printf(fmt, ...) |
Formatted emit (like string.format) |
lroff.printfln(fmt, ...) |
Formatted emit + newline |
lroff.request(name) |
Emit .name |
lroff.request_with(name, args) |
Emit .name args |
lroff.comment(text) |
Emit .\" text |
lroff.blank() |
Emit a blank line (paragraph break) |
| Function | Description |
|---|---|
lroff.escape(text) |
Escape \, leading . and ' for
safe groff output |
lroff.inline_escape(ec, arg) |
Build an inline escape like \f[B] or
\n[reg] |
| Function | Description |
|---|---|
lroff.bold(text) |
\fBtext\fP |
lroff.italic(text) |
\fItext\fP |
lroff.bold_italic(text) |
\f[BI]text\f[P] |
lroff.mono(text) |
\f[CR]text\f[P] |
lroff.styled(font, text) |
Generic: \f[font]text\f[P] |
lroff.special_char(name) |
\[name] (groff special character) |
| Function | Description |
|---|---|
lroff.font(f) |
.ft f |
lroff.font_bold() |
.ft B |
lroff.font_italic() |
.ft I |
lroff.font_roman() |
.ft R |
lroff.font_previous() |
.ft (revert) |
lroff.size(pts) |
.ps pts |
lroff.size_relative(delta) |
.ps +delta or .ps -delta |
| Function | Description |
|---|---|
lroff.nr_set(name, val) |
.nr name val + track in Lua |
lroff.nr_incr(name, delta) |
.nr name +delta |
lroff.nr_get(name) |
Return tracked value (or nil) |
lroff.nr_ref(name) |
Return string \n[name] for embedding in text |
lroff.ds_set(name, val) |
.ds name val |
lroff.ds_get(name) |
Return tracked value (or nil) |
lroff.ds_ref(name) |
Return string \*[name] |
| Function | Description |
|---|---|
lroff.divert_begin(name) |
Start capturing output into buffer name |
lroff.divert_end() |
Stop capturing |
lroff.divert_emit(name) |
Replay captured content |
lroff.divert_get(name) |
Return captured content as a Lua string |
lroff.divert_clear(name) |
Discard captured content |
| Function | Description |
|---|---|
lroff.title(text) |
.TL / text |
lroff.author(text) |
.AU / text |
lroff.section(text) |
.SH / text |
lroff.subsection(text) |
.SS / text |
lroff.paragraph(macro?) |
.PP (default) or .LP, .IP,
etc. |
lroff.display_begin(type?) |
.DS or .DS type |
lroff.display_end() |
.DE |
| Function | Description |
|---|---|
lroff.table(headers, rows, fmt?) |
Full tbl table |
lroff.bullet_list(items) |
Bullet list via .IP \(bu 2 |
lroff.numbered_list(items) |
Numbered list via .IP n. 4 |
lroff.def_list(items) |
Definition list via .TP |
| Function | Description |
|---|---|
lroff.macro_define(name, body) |
.de name / body / .. |
lroff.macro_define_lua(name, code) |
Define a macro whose body is a .lua block |
| Function | Description |
|---|---|
lroff.unique(prefix?) |
Return a unique name like _lua1, _lua2,
… |
lroff.version() |
Return "pplua 0.1.0" |
lroff.map(tbl, fn) |
Apply fn to each element; emit non-nil returns |
lroff.foreach(tbl, fn) |
Call fn(k,v) for each pair |
lroff.with_font(f, fn) |
Scoped font change |
lroff.with_size(s, fn) |
Scoped size change |
lroff.indented(fn) |
Wrap fn() in .RS / .RE |
lroff.groff_if(cond, body) |
Emit .if cond \{ body \} |
lroff.groff_while(cond, body) |
Emit .while cond \{ body \} |
lroff.concat(tbl, sep?) |
Like table.concat but calls tostring |
This demonstrates using Lua tables as a structured data source, then generating an entire formatted document from them.
.\" release-notes.roff
.\" Build: pplua release-notes.roff | tbl | groff -ms -Tpdf > release.pdf
.
.lua
-- ============================================================
-- Structured release data — could also be loaded from a file
-- with dofile("releases.lua")
-- ============================================================
releases = {
{
version = "3.2.0",
date = "2026-02-20",
status = "stable",
changes = {
{ type = "feature", desc = "Added ARM64 JIT backend" },
{ type = "feature", desc = "New streaming JSON parser" },
{ type = "bugfix", desc = "Fixed race condition in thread pool (#4217)" },
{ type = "bugfix", desc = "Corrected off-by-one in ring buffer" },
{ type = "perf", desc = "Reduced allocator overhead by 18%" },
},
},
{
version = "3.1.2",
date = "2026-01-05",
status = "maintenance",
changes = {
{ type = "bugfix", desc = "Patched CVE-2026-00142 in TLS handshake" },
{ type = "bugfix", desc = "Fixed SIGPIPE handling on FreeBSD" },
{ type = "docs", desc = "Rewrote the configuration chapter" },
},
},
{
version = "3.1.1",
date = "2025-11-30",
status = "maintenance",
changes = {
{ type = "bugfix", desc = "Locale-dependent date formatting corrected" },
{ type = "perf", desc = "Batch insert now 2x faster on large datasets" },
},
},
}
-- helper: icon for change type
function change_icon(t)
if t == "feature" then return lroff.special_char("*>") -- ✱
elseif t == "bugfix" then return lroff.special_char("bu") -- •
elseif t == "perf" then return lroff.special_char("ua") -- ↑
elseif t == "docs" then return lroff.special_char("lh") -- ☞
else return lroff.special_char("em") -- —
end
end
-- helper: human label
function change_label(t)
local labels = {
feature = "New Feature",
bugfix = "Bug Fix",
perf = "Performance",
docs = "Documentation",
}
return labels[t] or t
end
.endlua
.
.\" ---- Title page ----
.lua
lroff.title("Project Helios " .. lroff.special_char("em") .. " Release Notes")
lroff.author("Engineering Team")
.endlua
.LP
.DA
.AB
This document covers the last \lua'#releases' releases of
Project Helios, automatically generated from structured Lua data.
.AE
.
.\" ---- Generate a section for each release ----
.lua
for _, rel in ipairs(releases) do
-- Section heading with version, date, and status badge
lroff.section(string.format("Version %s (%s) [%s]",
rel.version, rel.date, rel.status:upper()))
-- Summary table: count changes by type
local counts = {}
for _, c in ipairs(rel.changes) do
counts[c.type] = (counts[c.type] or 0) + 1
end
-- Build the summary as a small tbl table
local hdr = {"Category", "Count"}
local rows = {}
for _, t in ipairs({"feature", "bugfix", "perf", "docs"}) do
if counts[t] then
rows[#rows + 1] = {change_label(t), tostring(counts[t])}
end
end
if #rows > 0 then
lroff.paragraph()
lroff.emitln(lroff.italic("Change summary:"))
lroff.blank()
lroff.table(hdr, rows, "center;")
end
-- Detailed list
lroff.paragraph()
lroff.emitln(lroff.italic("Details:"))
for _, c in ipairs(rel.changes) do
lroff.emitln(string.format(
".IP \"%s %s\" 4",
change_icon(c.type),
lroff.bold(change_label(c.type))
))
lroff.emitln(c.desc)
end
end
.endluaWhat’s happening: A single Lua table holds all the release data. The loop generates one groff section per release, with an auto-computed summary table and a categorized change list. Adding a new release means adding a table entry — zero groff editing.
This creates a fantasy creature compendium where each creature’s stat block, description, and danger rating are computed from base attributes.
.\" bestiary.roff
.\" Build: pplua bestiary.roff | tbl | groff -ms -Tpdf > bestiary.pdf
.
.lua
-- ============================================================
-- Creature database
-- ============================================================
creatures = {
{
name = "Ember Wyrm",
class = "Dragon",
habitat = "Volcanic caverns",
str = 18, dex = 12, con = 20, int = 14, wis = 10, cha = 16,
abilities = {"Fire Breath (60 ft. cone)", "Tremorsense",
"Frightful Presence"},
lore = [[A serpentine dragon that nests in pools of magma.
Its scales glow like banked coals, and its breath can melt
castle walls. Ancient texts warn: where the ground steams
and stone runs like water, the Ember Wyrm is near.]],
},
{
name = "Gloom Stalker",
class = "Aberration",
habitat = "Deep forest, twilight zones",
str = 14, dex = 20, con = 12, int = 6, wis = 18, cha = 4,
abilities = {"Shadow Step", "Paralyzing Gaze",
"Spider Climb"},
lore = [[A creature of pure shadow that hunts at dusk.
It moves between patches of darkness as if stepping through
doors. Victims report a chill and a sense of being watched
before the creature strikes with terrifying speed.]],
},
{
name = "Thornback Basilisk",
class = "Monstrosity",
habitat = "Arid scrublands, ruins",
str = 16, dex = 8, con = 18, int = 3, wis = 12, cha = 6,
abilities = {"Petrifying Gaze", "Venomous Bite",
"Thorn Shield (1d6 reflect)"},
lore = [[An eight-legged reptile covered in calcite spines.
Its gaze petrifies prey, which it later consumes at leisure.
Alchemists prize its blood for stoneshaping elixirs, but
harvesting it is, unsurprisingly, hazardous.]],
},
}
-- Compute derived stats
function modifier(score)
return math.floor((score - 10) / 2)
end
function mod_str(score)
local m = modifier(score)
if m >= 0 then return string.format("%d (+%d)", score, m)
else return string.format("%d (%d)", score, m)
end
end
function danger_rating(c)
-- simple formula: average of all stats, scaled
local avg = (c.str + c.dex + c.con + c.int + c.wis + c.cha) / 6
if avg >= 17 then return "Lethal", "\\m[red]Lethal\\m[]"
elseif avg >= 13 then return "Dangerous", "\\m[red3]Dangerous\\m[]"
elseif avg >= 9 then return "Moderate", "Moderate"
else return "Low", "Low"
end
end
.endlua
.
.\" ---- Document header ----
.lua
lroff.title("Bestiary of the Shattered Realms")
lroff.author("The Cartographer's Guild")
.endlua
.LP
.AB
A field guide to \lua'#creatures' creatures encountered in the
Shattered Realms. Danger ratings are computed from base
attributes. Handle with care.
.AE
.
.\" ---- Table of Contents (dynamically built) ----
.lua
lroff.section("TABLE OF CONTENTS")
lroff.blank()
for i, c in ipairs(creatures) do
local _, dr_plain = danger_rating(c)
lroff.printfln(".IP %d. 4", i)
lroff.printfln("%s %s %s",
lroff.bold(c.name),
lroff.special_char("em"),
c.class)
end
.endlua
.
.\" ---- Creature entries ----
.lua
for _, c in ipairs(creatures) do
local dr_text, dr_styled = danger_rating(c)
-- Section heading
lroff.section(c.name)
-- Classification line
lroff.paragraph()
lroff.printfln("%s %s | %s %s | %s %s",
lroff.bold("Class:"), c.class,
lroff.bold("Habitat:"), c.habitat,
lroff.bold("Danger:"), dr_styled)
-- Stat block as a tbl table
lroff.blank()
lroff.table(
{"STR", "DEX", "CON", "INT", "WIS", "CHA"},
{{mod_str(c.str), mod_str(c.dex), mod_str(c.con),
mod_str(c.int), mod_str(c.wis), mod_str(c.cha)}},
"center box;"
)
-- Abilities
lroff.blank()
lroff.paragraph()
lroff.emitln(lroff.bold("Abilities:"))
lroff.bullet_list(c.abilities)
-- Lore
lroff.paragraph()
lroff.emitln(lroff.italic("Field Notes:"))
lroff.paragraph("QP")
-- Escape the lore text so that any '.' at line start is safe
lroff.emitln(lroff.escape(c.lore))
end
.endluaWhat’s happening: Modifier values (⌊(score − 10)/2⌋) and danger ratings are
computed in Lua. The stat block is a tbl table generated
per creature. Adding a creature means adding a table entry — the entire
stat block, ability list, and formatting propagate automatically.
This example generates a system administrator’s configuration reference where the configuration schema is defined in Lua, validated, and rendered as both documentation and an example config file (via diversions).
.\" config-guide.roff
.\" Build: pplua config-guide.roff | tbl | groff -ms -Tpdf > config.pdf
.
.lua
-- ============================================================
-- Configuration schema
-- ============================================================
schema = {
{
key = "listen_address",
type = "string",
default = "0.0.0.0",
range = nil,
desc = "IP address to bind the server socket to.",
},
{
key = "listen_port",
type = "integer",
default = 8080,
range = {1, 65535},
desc = "TCP port number. Must be in the range 1\\(en65535.",
},
{
key = "max_connections",
type = "integer",
default = 1024,
range = {1, 100000},
desc = "Maximum simultaneous client connections.",
},
{
key = "tls_enabled",
type = "boolean",
default = false,
range = nil,
desc = "Enable TLS encryption. Requires tls_cert and tls_key.",
},
{
key = "tls_cert",
type = "path",
default = "/etc/myapp/cert.pem",
range = nil,
desc = "Path to the TLS certificate file (PEM format).",
},
{
key = "tls_key",
type = "path",
default = "/etc/myapp/key.pem",
range = nil,
desc = "Path to the TLS private key file.",
},
{
key = "log_level",
type = "enum",
default = "info",
range = {"debug", "info", "warn", "error", "fatal"},
desc = "Logging verbosity level.",
},
{
key = "worker_threads",
type = "integer",
default = 4,
range = {1, 256},
desc = "Number of worker threads in the request pool.",
},
}
-- Validation: check that defaults match declared types/ranges
warnings = {}
for _, opt in ipairs(schema) do
if opt.type == "integer" and opt.range then
if opt.default < opt.range[1] or opt.default > opt.range[2] then
warnings[#warnings + 1] = string.format(
"%s: default %s out of range [%d, %d]",
opt.key, tostring(opt.default),
opt.range[1], opt.range[2])
end
end
if opt.type == "enum" and opt.range then
local found = false
for _, v in ipairs(opt.range) do
if v == opt.default then found = true; break end
end
if not found then
warnings[#warnings + 1] = string.format(
"%s: default '%s' not in enum set",
opt.key, tostring(opt.default))
end
end
end
-- type badge
function type_badge(t)
return lroff.mono(t)
end
-- format range
function fmt_range(opt)
if not opt.range then return lroff.special_char("em") end
if opt.type == "integer" then
return string.format("%d \\(en %d", opt.range[1], opt.range[2])
elseif opt.type == "enum" then
local parts = {}
for _, v in ipairs(opt.range) do
parts[#parts + 1] = lroff.mono(v)
end
return table.concat(parts, " | ")
end
return tostring(opt.range)
end
.endlua
.
.\" ---- Document ----
.lua
lroff.title("myappd.conf " .. lroff.special_char("em") ..
" Configuration Reference")
lroff.author("Operations Team")
.endlua
.LP
.DA
.AB
Auto-generated reference for all \lua'#schema' configuration
directives of
.B myappd .
Validated at document build time (\lua'os.date("%Y-%m-%d %H:%M")').
.AE
.
.\" ---- Validation warnings (if any) ----
.lua
if #warnings > 0 then
lroff.section("BUILD WARNINGS")
lroff.paragraph()
lroff.emitln("The following schema validation issues were detected:")
lroff.blank()
lroff.bullet_list(warnings)
else
lroff.comment("Schema validation passed — no warnings.")
end
.endlua
.
.\" ---- Quick-reference summary table ----
.lua
lroff.section("SUMMARY TABLE")
lroff.paragraph()
local hdr = {"Directive", "Type", "Default"}
local rows = {}
for _, opt in ipairs(schema) do
rows[#rows + 1] = {
lroff.mono(opt.key),
type_badge(opt.type),
lroff.mono(tostring(opt.default)),
}
end
lroff.table(hdr, rows, "center box;")
.endlua
.
.\" ---- Detailed directive entries ----
.lua
lroff.section("DIRECTIVE REFERENCE")
for _, opt in ipairs(schema) do
-- Use .TP (tagged paragraph) style
lroff.emitln(".TP 4")
lroff.printfln("%s %s", lroff.bold(opt.key), type_badge(opt.type))
lroff.emitln(opt.desc)
-- Sub-details via indentation
lroff.indented(function()
lroff.emitln(lroff.bold("Default: ") ..
lroff.mono(tostring(opt.default)))
lroff.emitln(lroff.bold("Range: ") .. fmt_range(opt))
end)
lroff.blank()
end
.endlua
.
.\" ---- Example config file (built via diversion) ----
.lua
lroff.section("EXAMPLE CONFIGURATION FILE")
lroff.paragraph()
lroff.emitln("A complete example with all defaults:")
-- Build the example config into a diversion
lroff.divert_begin("example_config")
lroff.emitln("# myappd.conf — generated " .. os.date("%Y-%m-%d"))
lroff.emitln("#")
for _, opt in ipairs(schema) do
lroff.printfln("# %s", opt.desc:gsub("\n", " "))
if opt.type == "boolean" then
lroff.printfln("%s = %s",
opt.key, opt.default and "true" or "false")
else
lroff.printfln("%s = %s", opt.key, tostring(opt.default))
end
lroff.emitln("")
end
lroff.divert_end()
-- Now emit it inside a display
lroff.display_begin("I")
lroff.request("ft CR")
lroff.divert_emit("example_config")
lroff.request("ft")
lroff.display_end()
.endluaWhat’s happening: The schema is validated at document build time — if a default is out of range, a warning section appears in the PDF. The summary table, detailed reference, and example config file are all generated from the same schema table. Change the schema, rebuild, and every section updates consistently. The example config is captured in a diversion and then replayed inside a display block.
This shows Lua’s math.random being used to shuffle
multiple-choice answers while tracking the correct answer key — perfect
for generating unique exam variants.
.\" exam.roff
.\" Build: pplua -D SEED=42 exam.roff | groff -ms -Tpdf > exam.pdf
.
.lua
-- ============================================================
-- Seed the RNG (use -D SEED=N for reproducible variants)
-- ============================================================
local seed = tonumber(SEED) or os.time()
math.randomseed(seed)
-- Fisher-Yates shuffle, returns shuffled copy + new index of
-- the element that was originally at position 'correct_idx'
function shuffle_answers(answers, correct_idx)
local a = {}
for i, v in ipairs(answers) do a[i] = {text = v, orig = i} end
for i = #a, 2, -1 do
local j = math.random(i)
a[i], a[j] = a[j], a[i]
end
local new_correct
local texts = {}
for i, entry in ipairs(a) do
texts[i] = entry.text
if entry.orig == correct_idx then new_correct = i end
end
return texts, new_correct
end
-- Question bank
questions = {
{
text = "What is the time complexity of binary search on a sorted array of $n$ elements?",
answers = {"$O(n)$", "$O(log n)$", "$O(n log n)$", "$O(1)$"},
correct = 2,
},
{
text = "Which data structure uses LIFO (Last In, First Out) ordering?",
answers = {"Queue", "Stack", "Heap", "Deque"},
correct = 2,
},
{
text = "In Unix, which signal is sent by default when you press Ctrl+C?",
answers = {"SIGTERM", "SIGKILL", "SIGINT", "SIGHUP"},
correct = 3,
},
{
text = "What does the HTTP status code 418 mean?",
answers = {"Not Found", "I'm a teapot",
"Internal Server Error", "Unauthorized"},
correct = 2,
},
{
text = "Which sorting algorithm has a worst-case time complexity of $O(n^2)$ but is often fast in practice?",
answers = {"Merge Sort", "Heap Sort", "Quick Sort", "Radix Sort"},
correct = 3,
},
}
-- Shuffle and record answer key
answer_key = {}
for i, q in ipairs(questions) do
local shuffled, correct_pos = shuffle_answers(q.answers, q.correct)
q.shuffled = shuffled
q.shuffled_correct = correct_pos
answer_key[i] = correct_pos
end
-- Letter labels
function letter(n) return string.char(64 + n) end -- 1→A, 2→B, …
.endlua
.
.\" ---- Exam header ----
.lua
lroff.title("CS 201 " .. lroff.special_char("em") .. " Midterm Examination")
lroff.author("Department of Computer Science")
.endlua
.LP
.DA
.sp 1
.ce 2
Variant seed: \lua'seed'
Time allowed: 60 minutes
.sp 1
.LP
.B "Instructions:"
Answer all \lua'#questions' questions.
Circle the letter of the correct answer.
Each question is worth \lua'math.floor(100 / #questions)' points.
.
.\" ---- Questions ----
.lua
lroff.section("QUESTIONS")
for i, q in ipairs(questions) do
lroff.blank()
lroff.printfln(".IP %d. 4", i)
lroff.emitln(q.text)
lroff.blank()
for j, ans in ipairs(q.shuffled) do
lroff.printfln(".RS 4")
lroff.printfln(".IP %s) 4", letter(j))
lroff.emitln(ans)
lroff.request("RE")
end
end
.endlua
.
.\" ---- Page break, then answer key ----
.lua
lroff.request("bp")
lroff.section("ANSWER KEY (for instructor use only)")
lroff.paragraph()
lroff.printfln("Variant seed: %s", tostring(seed))
lroff.blank()
local hdr = {"#", "Answer"}
local rows = {}
for i, pos in ipairs(answer_key) do
rows[#rows + 1] = {tostring(i), lroff.bold(letter(pos))}
end
lroff.table(hdr, rows, "center box;")
.endluaWhat’s happening: Every build with a different
-D SEED=N produces a different answer order but the answer
key on the last page always stays correct. The instructor can generate
unique variants per exam room simply by changing the seed:
for s in 1 2 3 4 5; do
pplua -D SEED=$s exam.roff | groff -ms -Tpdf > exam-variant-$s.pdf
doneThis generates a quarterly financial summary with computed totals, percentages, year-over-year deltas, and ASCII “bar charts” built from groff drawing primitives.
.\" finance.roff
.\" Build: pplua finance.roff | tbl | groff -ms -Tpdf > finance.pdf
.
.lua
-- ============================================================
-- Financial data
-- ============================================================
quarters = {
{label = "Q1 2025", revenue = 1240000, expenses = 890000},
{label = "Q2 2025", revenue = 1510000, expenses = 920000},
{label = "Q3 2025", revenue = 1380000, expenses = 1010000},
{label = "Q4 2025", revenue = 1720000, expenses = 980000},
}
-- Compute derived fields
total_revenue = 0
total_expenses = 0
max_revenue = 0
for _, q in ipairs(quarters) do
q.profit = q.revenue - q.expenses
q.margin = (q.profit / q.revenue) * 100
total_revenue = total_revenue + q.revenue
total_expenses = total_expenses + q.expenses
if q.revenue > max_revenue then max_revenue = q.revenue end
end
total_profit = total_revenue - total_expenses
total_margin = (total_profit / total_revenue) * 100
-- Format a number as $X,XXX,XXX
function fmt_money(n)
local s = string.format("%.0f", n)
-- insert commas from right
local result = ""
local count = 0
for i = #s, 1, -1 do
if count > 0 and count % 3 == 0 then
result = "," .. result
end
result = s:sub(i,i) .. result
count = count + 1
end
return "$" .. result
end
-- Build a text bar: ████░░░░ style using groff's \[br] and spaces
-- Actually, we'll use a horizontal rule \l'Np' of proportional width
function bar(value, max_val, total_width_inches)
local width = (value / max_val) * total_width_inches
return string.format("\\l'%.2fi\\[ul]'", width)
end
-- QoQ delta
function delta_str(current, previous)
if not previous then return lroff.special_char("em") end
local pct = ((current - previous) / previous) * 100
if pct >= 0 then
return string.format("+%.1f%%", pct)
else
return string.format("%.1f%%", pct)
end
end
.endlua
.
.\" ---- Header ----
.lua
lroff.title("Quarterly Financial Summary " ..
lroff.special_char("em") .. " FY 2025")
lroff.author("Finance Department")
.endlua
.LP
.DA
.AB
Annual revenue: \lua'fmt_money(total_revenue)'.
Annual profit: \lua'fmt_money(total_profit)'
(\lua'string.format("%.1f%%", total_margin)' margin).
Report generated \lua'os.date("%B %d, %Y at %H:%M")'.
.AE
.
.\" ---- Summary Table ----
.lua
lroff.section("QUARTERLY BREAKDOWN")
lroff.paragraph()
local hdr = {"Quarter", "Revenue", "Expenses", "Profit",
"Margin", "QoQ " .. lroff.special_char("De")}
local rows = {}
for i, q in ipairs(quarters) do
local prev_rev = (i > 1) and quarters[i-1].revenue or nil
rows[#rows + 1] = {
lroff.bold(q.label),
fmt_money(q.revenue),
fmt_money(q.expenses),
fmt_money(q.profit),
string.format("%.1f%%", q.margin),
delta_str(q.revenue, prev_rev),
}
end
-- Totals row
rows[#rows + 1] = {
lroff.bold("TOTAL"),
lroff.bold(fmt_money(total_revenue)),
lroff.bold(fmt_money(total_expenses)),
lroff.bold(fmt_money(total_profit)),
lroff.bold(string.format("%.1f%%", total_margin)),
"",
}
lroff.table(hdr, rows, "center allbox;")
.endlua
.
.\" ---- Visual chart ----
.lua
lroff.section("REVENUE TREND")
lroff.paragraph()
lroff.emitln("Visual comparison (proportional bars):")
lroff.blank()
for _, q in ipairs(quarters) do
lroff.printfln(".IP \"%s\" 12", q.label)
lroff.printfln("%s %s",
bar(q.revenue, max_revenue, 3.0),
fmt_money(q.revenue))
end
.endlua
.
.\" ---- Margin analysis ----
.lua
lroff.section("MARGIN ANALYSIS")
lroff.paragraph()
-- Find best and worst quarters
local best = quarters[1]
local worst = quarters[1]
for _, q in ipairs(quarters) do
if q.margin > best.margin then best = q end
if q.margin < worst.margin then worst = q end
end
lroff.emitln("The " .. lroff.bold("best") ..
" margin was in " .. lroff.bold(best.label) ..
string.format(" at %.1f%%.", best.margin))
lroff.emitln("The " .. lroff.bold("worst") ..
" margin was in " .. lroff.bold(worst.label) ..
string.format(" at %.1f%%.", worst.margin))
lroff.blank()
lroff.emitln("Detailed margin breakdown:")
lroff.blank()
-- Definition list of margin per quarter
local items = {}
for _, q in ipairs(quarters) do
items[#items + 1] = {
q.label,
string.format("%.1f%% margin on %s revenue (%s profit)",
q.margin, fmt_money(q.revenue), fmt_money(q.profit))
}
end
lroff.def_list(items)
.endluaWhat’s happening: All financial computations —
totals, margins, quarter-over-quarter deltas, best/worst detection —
happen in Lua. The proportional bar chart uses groff’s \l
horizontal line escape, computed to a fractional-inch width: $ = , $.
Update the quarters table and every figure, table, chart,
and narrative sentence updates.
-D for Language
SelectionThis shows how -D flags on the command line can select
content variants — the same source file produces phrasebooks for
different target languages.
.\" phrasebook.roff
.\" Build:
.\" pplua -D LANG=fr phrasebook.roff | groff -ms -Tpdf > french.pdf
.\" pplua -D LANG=de phrasebook.roff | groff -ms -Tpdf > german.pdf
.\" pplua -D LANG=ja phrasebook.roff | groff -ms -Tpdf > japanese.pdf
.
.lua
-- ============================================================
-- Phrase database
-- ============================================================
phrases = {
{
en = "Hello",
fr = "Bonjour",
de = "Hallo",
ja = "\\(kokonnichiwa\\(co", -- placeholder romanization
category = "Greetings",
},
{
en = "Thank you",
fr = "Merci",
de = "Danke",
ja = "Arigatou",
category = "Greetings",
},
{
en = "Where is the train station?",
fr = "O\\(`u est la gare?",
de = "Wo ist der Bahnhof?",
ja = "Eki wa doko desu ka?",
category = "Travel",
},
{
en = "How much does this cost?",
fr = "Combien \\(,ca co\\(^ute?",
de = "Was kostet das?",
ja = "Ikura desu ka?",
category = "Shopping",
},
{
en = "I would like a coffee, please.",
fr = "Je voudrais un caf\\('e, s'il vous pla\\(^it.",
de = "Ich h\\(\"atte gerne einen Kaffee, bitte.",
ja = "Koohii o kudasai.",
category = "Food & Drink",
},
{
en = "Excuse me",
fr = "Excusez-moi",
de = "Entschuldigung",
ja = "Sumimasen",
category = "Politeness",
},
}
-- Determine target language from -D LANG=xx
target = LANG or "fr"
lang_names = {
fr = "French",
de = "German",
ja = "Japanese",
}
lang_name = lang_names[target] or target:upper()
-- Group phrases by category
categories = {}
cat_order = {}
for _, p in ipairs(phrases) do
if not categories[p.category] then
categories[p.category] = {}
cat_order[#cat_order + 1] = p.category
end
table.insert(categories[p.category], p)
end
.endlua
.
.\" ---- Header ----
.lua
lroff.title("Traveler's Phrasebook: English " ..
lroff.special_char("->") .. " " .. lang_name)
lroff.author("pplua Language Tools")
.endlua
.LP
.DA
.AB
A pocket phrasebook containing \lua'#phrases' essential phrases
in \lua'lang_name', organized by category.
Target language code:
.B "\lua'target'" .
.AE
.
.\" ---- Phrases by category ----
.lua
for _, cat in ipairs(cat_order) do
lroff.section(cat:upper())
lroff.blank()
local hdr = {"English", lang_name}
local rows = {}
for _, p in ipairs(categories[cat]) do
rows[#rows + 1] = {p.en, lroff.bold(p[target] or "(?)")}
end
lroff.table(hdr, rows, "center box;")
end
.endlua
.
.\" ---- Quick-reference card (all phrases, compact) ----
.lua
lroff.section("QUICK REFERENCE")
lroff.paragraph()
lroff.emitln("All phrases at a glance:")
lroff.blank()
local all_items = {}
for _, p in ipairs(phrases) do
all_items[#all_items + 1] = {
p.en,
lroff.bold(p[target] or "(?)")
}
end
lroff.def_list(all_items)
.endluaWhat’s happening: A single source generates
different phrasebooks depending on the -D LANG=xx flag. The
phrase database, category grouping, table generation, and definition
list are all driven by data. Adding a new language means adding one key
per phrase — no structural changes.
Keep your data in a separate .lua file and load it with
-l:
pplua -l project_data.lua report.roff | groff -ms -Tpdf > report.pdf-- project_data.lua
project = {
name = "Helios",
version = "3.2.0",
modules = { ... },
}Place shared functions in a Lua module and add its path with
-I:
pplua -I ./lib report.roff | groff -ms -Tpdf > report.pdf-- lib/formatting.lua
local M = {}
function M.fmt_money(n) ... end
function M.fmt_pct(n) ... end
return M.lua
fmt = require("formatting")
lroff.emitln(fmt.fmt_money(1234567))
.endluaUse -D flags and Lua conditionals to produce different
document variants from a single source:
.lua
if DRAFT then
lroff.emitln(".ds LH DRAFT")
lroff.emitln(".ds RH " .. os.date("%Y-%m-%d"))
end
.endluapplua -D DRAFT=1 paper.roff | groff -ms -Tpdf > draft.pdf
pplua paper.roff | groff -ms -Tpdf > final.pdfLua’s io library is available, so you can ingest CSV,
JSON (with a pure-Lua parser), or any text:
.lua
-- Read a CSV and generate a table
local rows = {}
for line in io.lines("data.csv") do
local fields = {}
for field in line:gmatch("[^,]+") do
fields[#fields + 1] = field
end
rows[#rows + 1] = fields
end
local headers = table.remove(rows, 1)
lroff.table(headers, rows, "center box;")
.endluaUse -e to inject debug helpers:
# Print all lroff output to stderr as well as stdout
pplua -e 'DEBUG=true' doc.roff | groff -ms -Tpdf > doc.pdfInside your document:
.lua
if DEBUG then
io.stderr:write("Processing section: " .. title .. "\n")
end
.endluaInline \lua'…' is perfect for short computed values in
running prose. Avoid putting complex logic inside it — use a block to
define variables, then reference them inline:
.lua
total = 0
for _, q in ipairs(quarters) do total = total + q.revenue end
.endlua
.LP
The annual revenue was \lua'fmt_money(total)', representing
a \lua'string.format("%.1f", growth_pct)'% increase over the
prior fiscal year.pplua is designed to be a well-behaved Unix filter. It
reads from files or stdin, writes to stdout, and reports errors to
stderr. This means it slots cleanly into any groff pipeline:
# Full pipeline with all classical preprocessors
pplua doc.roff | soelim | tbl | eqn | pic | groff -ms -Tpdf > doc.pdf
# In a Makefile
%.pdf: %.roff
pplua $< | tbl | eqn | groff -ms -Tpdf > $@
# Processing multiple input files (concatenated)
pplua front.roff body.roff back.roff | groff -ms -Tpdf > book.pdfThe .lf directives emitted by pplua (unless
suppressed with -n) ensure that groff error messages point
back to the correct line in your original source, not the post-processed
output — just like soelim, tbl, and
eqn do.
┌──────────────────────────────────────────────────────┐
│ .lua Start a Lua block │
│ .endlua End a Lua block │
│ \lua'expr' Inline expression (value inserted) │
│ │
│ lroff.emit(s) Raw output │
│ lroff.emitln(s) Raw output + newline │
│ lroff.request(r) Emit .r │
│ lroff.bold(s) Return \fBs\fP │
│ lroff.italic(s) Return \fIs\fP │
│ lroff.mono(s) Return \f[CR]s\f[P] │
│ lroff.section(s) Emit .SH / s │
│ lroff.table(h,r) Emit tbl table │
│ lroff.bullet_list(t) Emit bullet list │
│ lroff.def_list(t) Emit definition list │
│ lroff.escape(s) Escape text for groff │
│ lroff.nr_set(n,v) Set number register │
│ lroff.ds_set(n,v) Set string register │
│ lroff.divert_begin(n) Start diversion │
│ lroff.divert_end() End diversion │
│ lroff.divert_emit(n) Replay diversion │
│ lroff.unique(pfx) Unique name generator │
└──────────────────────────────────────────────────────┘