Files
business-suite/bin/tables.lua
T

152 lines
6.1 KiB
Lua
Raw Permalink Normal View History

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