152 lines
6.1 KiB
Lua
152 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
|