Files
business-suite/bin/tables.lua
T
Marc WäckerlinandClaude Opus 5 adc58e19d7 One LaTeX design for a company, in three templates
A company that writes with LaTeX ends up with a document class, a letter class
and a presentation theme that share a design and not a line of code. Every fix
is then made three times, two of them late and the third never.

Here the page is written once. A company declares its colours, faces and logo in
one file, and the document template, the letter class and the presentation theme
read that one file. Nothing in the suite carries a colour value, a font name or
a file name of any company, which is what lets it be published while the
companies stay private.

Every measure of the page follows from a measurement or from a named definition:
the room the head and the foot need is taken from the boxes they really build,
one line of the body text stands between the head and the text and between the
text and the foot, a heading never stands alone at the foot of a page, a picture
takes the size of the family, and a Markdown file reaches the same page as the
same document written in LaTeX.

The suite ships with a company that does not exist, Nordwind AG, so that it
builds and is measured anywhere: three colours, the TeX Gyre families every
installation carries, and an icon drawn in TikZ.

574 checks over five measurements, all of them on the rendered page or on the
build log rather than on the source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 15:06:07 +02:00

153 lines
6.1 KiB
Lua

-- Copyright (c) 2026 Marc Wäckerlin. SPDX-License-Identifier: MIT
-- A table that is wider than the line gets relative column widths.
--
-- A Markdown pipe table says nothing about how wide its columns are, so pandoc
-- writes LaTeX columns that never break a line. A table whose content is longer
-- than the line then runs out of the type area and the last words stand outside
-- the paper. With relative widths LaTeX wraps the cells.
--
-- The widths are shares of the space the columns have between them; pandoc
-- writes every column as `p{(\linewidth - 2(n-1)\tabcolsep) * \real{w}}` and has
-- therefore already taken the space between the columns off the line, so the
-- shares here add up to one and to nothing else.
--
-- How wide the line is and how much of it a character takes are MEASURED, and
-- they arrive in the environment: the converter builds a probe document with
-- the identity of the company and reads `\linewidth` and the width of a reference
-- line out of it. Both numbers belong to the paper, the margins and the face of
-- the company, and none of the three is the same in two companies. The fallback is
-- the measurement of the origin of this family, for a filter that is run
-- without the converter.
--
-- Every table takes the whole line, whether its content needs it or not: a page
-- of tables that each end somewhere else has no edge to read along.
local TYPE_AREA = tonumber(os.getenv('MD2PDF_LINEWIDTH')) or 538
local CHARACTER = tonumber(os.getenv('MD2PDF_CHARACTER')) or 5.3
-- What LaTeX keeps between any two columns, `2\tabcolsep`, whatever the size of
-- the text.
local GAP = 12
-- What a column needs at the very least: its longest word, plus the space that
-- keeps it off its neighbour. A column narrower than that pushes its word into
-- the next column, measured at eight columns where `Production` printed over
-- `Test` and a word stood 23 points outside the page, while the shares still
-- added up to one.
local SEPARATOR = 3
-- Where even the least widths do not fit into the line, no share of it can
-- help: the table needs more characters than the line holds, and the way a
-- typographer gets them is a smaller face. The factors are the sizes of the
-- class against the body size — at 10pt, \small is 9pt, \footnotesize 8pt and
-- \scriptsize 7pt — so a line holds that much more of them.
local SIZES = {
{ name = '', factor = 1 },
{ name = '\\small', factor = 10 / 9 },
{ name = '\\footnotesize', factor = 10 / 8 },
{ name = '\\scriptsize', factor = 10 / 7 },
}
local function measure(tbl)
local columns = #tbl.colspecs
local longest, word = {}, {}
for index = 1, columns do
longest[index], word[index] = 0, 1
end
local function scan(rows)
for _, row in ipairs(rows) do
for index, cell in ipairs(row.cells) do
if index <= columns then
local text = pandoc.utils.stringify(cell)
longest[index] = math.max(longest[index], #text)
for piece in text:gmatch('%S+') do
word[index] = math.max(word[index], #piece)
end
end
end
end
end
scan(tbl.head.rows)
for _, body in ipairs(tbl.bodies) do
scan(body.body)
end
return longest, word
end
-- The head of a table is marked as the head. pandoc writes the row that names
-- the columns in the face of the body, so a head and a first row look the same;
-- a document written by hand marks its head cells with `\businesstablehead{…}`,
-- and this puts the same command around every head cell of a Markdown table.
-- How a head looks is then the package's decision, once for both ways.
local function mark_head(tbl)
local open = pandoc.RawInline('latex', '\\businesstablehead{')
local close = pandoc.RawInline('latex', '}')
for _, row in ipairs(tbl.head.rows) do
for _, cell in ipairs(row.cells) do
local function marked(inlines)
return pandoc.Inlines({ open }) .. inlines .. pandoc.Inlines({ close })
end
cell.contents = cell.contents:walk({
Para = function(block) return pandoc.Para(marked(block.content)) end,
Plain = function(block) return pandoc.Plain(marked(block.content)) end,
})
end
end
end
function Table(tbl)
if not FORMAT:match('latex') then return nil end
local columns = #tbl.colspecs
if columns == 0 then return nil end
mark_head(tbl)
for _, spec in ipairs(tbl.colspecs) do
if spec[2] then return nil end
end
local longest, word = measure(tbl)
local content, needed = 0, 0
for index = 1, columns do
content = content + longest[index]
needed = needed + word[index] + SEPARATOR
end
-- What this table has room for, in characters of the body size: its own
-- width, which is the type area minus the space between its columns.
local room = (TYPE_AREA - GAP * (columns - 1)) / CHARACTER
if content == 0 then
return nil
end
-- The face is chosen first: the smallest step at which the columns can hold
-- their longest words side by side, and the body size wherever that already
-- works.
local size = SIZES[#SIZES]
for _, candidate in ipairs(SIZES) do
if needed <= room * candidate.factor then
size = candidate
break
end
end
local line = room * size.factor
-- Then the width: every column keeps its longest word, and what is left over
-- goes to the columns in proportion to how much text they carry, so the
-- column with the sentences grows and the one with the numbers stays as
-- narrow as its heading. Where not even the smallest face gives room for
-- every word, the least widths are scaled down together and LaTeX breaks
-- inside the words, in the language of the document.
local least = needed / line
local share = {}
for index = 1, columns do
local minimum = (word[index] + SEPARATOR) / line
share[index] = least >= 1 and minimum / least
or minimum + (1 - least) * longest[index] / content
end
local specs = {}
for index, spec in ipairs(tbl.colspecs) do
specs[index] = { spec[1], share[index] }
end
tbl.colspecs = specs
if size.name == '' then return tbl end
return { pandoc.RawBlock('latex', '\\begingroup' .. size.name),
tbl,
pandoc.RawBlock('latex', '\\endgroup') }
end