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