|
| 1 | +-- lua filter for RST-like list-tables in Markdown. |
| 2 | +-- Copyright (C) 2021 Martin Fischer, released under MIT license |
| 3 | + |
| 4 | +-- Changes for use in Quarto are |
| 5 | +-- Copyright (C) 2025 Posit Software, PBC |
| 6 | + |
| 7 | +-- Get the list of cells in a row. |
| 8 | +local row_cells = function (row) return row.cells or {} end |
| 9 | + |
| 10 | +local alignments = { |
| 11 | + d = 'AlignDefault', |
| 12 | + l = 'AlignLeft', |
| 13 | + r = 'AlignRight', |
| 14 | + c = 'AlignCenter' |
| 15 | +} |
| 16 | + |
| 17 | +-- This is like assert() but it can take a Block or Blocks 'where' argument |
| 18 | +-- and will output the corresponding markdown (truncated at 1024 characters). |
| 19 | +local function assert_(assertion, message, where) |
| 20 | + message = message or 'assertion failed!' |
| 21 | + if not assertion then |
| 22 | + local extra = '' |
| 23 | + if where then |
| 24 | + local blocks = pandoc.Blocks(where) |
| 25 | + local markdown = pandoc.write(pandoc.Pandoc(blocks), 'markdown') |
| 26 | + extra = ' at\n' .. markdown:sub(1, 1024) .. |
| 27 | + (#markdown > 1024 and '...' or '') |
| 28 | + end |
| 29 | + error(message .. extra, 2) |
| 30 | + end |
| 31 | +end |
| 32 | + |
| 33 | +-- Skip data-pos Divs inserted by the sourcepos extension |
| 34 | +local function block_skip_data_pos(block) |
| 35 | + if (block.t == "Div" and block.attr.attributes["data-pos"]) then |
| 36 | + block = block.content[1] |
| 37 | + end |
| 38 | + return block |
| 39 | +end |
| 40 | + |
| 41 | +local function blocks_skip_data_pos(blocks) |
| 42 | + local new_blocks = {} |
| 43 | + for _, block in ipairs(blocks) do |
| 44 | + table.insert(new_blocks, block_skip_data_pos(block)) |
| 45 | + end |
| 46 | + return new_blocks |
| 47 | +end |
| 48 | + |
| 49 | +local function get_colspecs(div_attributes, column_count) |
| 50 | + -- list of (align, width) pairs |
| 51 | + local colspecs = {} |
| 52 | + |
| 53 | + for i = 1, column_count do |
| 54 | + table.insert(colspecs, {pandoc.AlignDefault, nil}) |
| 55 | + end |
| 56 | + |
| 57 | + if div_attributes.aligns then |
| 58 | + local i = 1 |
| 59 | + for a in div_attributes.aligns:gmatch('[^,]+') do |
| 60 | + assert_(alignments[a] ~= nil, |
| 61 | + "unknown column alignment " .. tostring(a)) |
| 62 | + colspecs[i][1] = alignments[a] |
| 63 | + i = i + 1 |
| 64 | + end |
| 65 | + div_attributes.aligns = nil |
| 66 | + end |
| 67 | + |
| 68 | + if div_attributes.widths then |
| 69 | + local total = 0 |
| 70 | + local widths = {} |
| 71 | + for w in div_attributes.widths:gmatch('[^,]+') do |
| 72 | + table.insert(widths, tonumber(w)) |
| 73 | + total = total + tonumber(w) |
| 74 | + end |
| 75 | + for i = 1, column_count do |
| 76 | + colspecs[i][2] = widths[i] / total |
| 77 | + end |
| 78 | + div_attributes.widths = nil |
| 79 | + end |
| 80 | + |
| 81 | + return colspecs |
| 82 | +end |
| 83 | + |
| 84 | +local function new_table_body(rows, attr, header_col_count) |
| 85 | + attr = attr or {} |
| 86 | + return { |
| 87 | + attr = attr, |
| 88 | + body = rows, |
| 89 | + head = {}, |
| 90 | + row_head_columns = header_col_count |
| 91 | + } |
| 92 | +end |
| 93 | + |
| 94 | +local function new_cell(contents) |
| 95 | + local attr = {} |
| 96 | + local colspan = 1 |
| 97 | + local rowspan = 1 |
| 98 | + local align = pandoc.AlignDefault |
| 99 | + |
| 100 | + contents = blocks_skip_data_pos(contents) |
| 101 | + |
| 102 | + -- At the time of writing this Pandoc does not support attributes |
| 103 | + -- on list items, so we use empty spans as a workaround. |
| 104 | + if contents[1] and contents[1].content then |
| 105 | + if contents[1].content[1] and contents[1].content[1].t == "Span" then |
| 106 | + if #contents[1].content[1].content == 0 then |
| 107 | + attr = contents[1].content[1].attr |
| 108 | + table.remove(contents[1].content, 1) |
| 109 | + colspan = attr.attributes.colspan or 1 |
| 110 | + attr.attributes.colspan = nil |
| 111 | + rowspan = attr.attributes.rowspan or 1 |
| 112 | + attr.attributes.rowspan = nil |
| 113 | + align = alignments[attr.attributes.align] or pandoc.AlignDefault |
| 114 | + attr.attributes.align = nil |
| 115 | + end |
| 116 | + end |
| 117 | + end |
| 118 | + |
| 119 | + return pandoc.Cell(contents, align, rowspan, colspan, attr) |
| 120 | +end |
| 121 | + |
| 122 | +local function process(div) |
| 123 | + if (div.attr.classes[1] ~= "list-table" and |
| 124 | + div.attr.classes[1] ~= "list-table-body") then return nil end |
| 125 | + local class = div.attr.classes[1] |
| 126 | + table.remove(div.attr.classes, 1) |
| 127 | + |
| 128 | + if #div.content == 0 then return nil end |
| 129 | + |
| 130 | + local content = blocks_skip_data_pos(div.content) |
| 131 | + |
| 132 | + local caption = {} |
| 133 | + if content[1].t == "Para" then |
| 134 | + local para = table.remove(content, 1) |
| 135 | + caption = {pandoc.Plain(para.content)} |
| 136 | + end |
| 137 | + |
| 138 | + if #content == 0 then return nil end |
| 139 | + |
| 140 | + assert_(content[1].t == "BulletList", |
| 141 | + "expected bullet list, found " .. content[1].t, content[1]) |
| 142 | + local list = content[1] |
| 143 | + |
| 144 | + -- rows points to the current body's rows |
| 145 | + local bodies = {attr=nil, {rows={}}} |
| 146 | + local rows = bodies[#bodies].rows |
| 147 | + |
| 148 | + for i = 1, #list.content do |
| 149 | + local attr = nil |
| 150 | + local items = list.content[i] |
| 151 | + if (#items > 1) then |
| 152 | + local item = block_skip_data_pos(items[1]) |
| 153 | + assert_(item.content, "expected list item to have row attrs", |
| 154 | + item) |
| 155 | + assert_(#item.content == 1, "expected row attrs to contain " .. |
| 156 | + "only one inline", item.content) |
| 157 | + assert_(item.content[1].t == "Span", "expected row attrs to " .. |
| 158 | + "contain a span", item.content[1]) |
| 159 | + assert_(#item.content[1].content == 0, "expected row attrs " .. |
| 160 | + "span to be empty", item.content[1]) |
| 161 | + attr = item.content[1].attr |
| 162 | + table.remove(items, 1) |
| 163 | + end |
| 164 | + |
| 165 | + assert_(#items == 1, "expected item to contain only one block", items) |
| 166 | + |
| 167 | + local item = block_skip_data_pos(items[1]) |
| 168 | + if (item.t ~= 'Table') then |
| 169 | + assert_(item.t == "BulletList", "expected bullet list, found " .. |
| 170 | + item.t, item) |
| 171 | + local cells = {} |
| 172 | + for _, cell_content in pairs(item.content) do |
| 173 | + table.insert(cells, new_cell(cell_content)) |
| 174 | + end |
| 175 | + local row = pandoc.Row(cells, attr) |
| 176 | + table.insert(rows, row) |
| 177 | + |
| 178 | + else |
| 179 | + local tab = item |
| 180 | + -- XXX is there a better way to check that there's no caption? |
| 181 | + assert_((not tab.caption.long or #tab.caption.long == 0) and |
| 182 | + (not tab.caption.short or #tab.caption.short == 0), |
| 183 | + "table bodies can't have captions (they'd be " .. |
| 184 | + "ignored)", tab) |
| 185 | + -- XXX would have to check against default colspecs to know whether |
| 186 | + -- any have been defined? |
| 187 | + -- assert_(#tab.colspecs == 0, "table bodies can't (yet) have " .. |
| 188 | + -- "column specs", tab) |
| 189 | + -- XXX should allow empty headers; this can happen with pipe tables |
| 190 | + -- assert_(not tab.head or #tab.head.rows == 0, |
| 191 | + -- "table bodies can't (yet) have headers", tab) |
| 192 | + assert_(#tab.bodies == 1, "table bodies can't contain other " .. |
| 193 | + "table bodies", tab) |
| 194 | + |
| 195 | + if #rows > 0 then |
| 196 | + table.insert(bodies, {attr=nil, rows={}}) |
| 197 | + rows = bodies[#bodies].rows |
| 198 | + end |
| 199 | + |
| 200 | + bodies[#bodies].attr = tab.attr |
| 201 | + for _, row in ipairs(tab.bodies[1].body) do |
| 202 | + table.insert(rows, row) |
| 203 | + end |
| 204 | + end |
| 205 | + end |
| 206 | + |
| 207 | + -- switch back to the first body |
| 208 | + rows = bodies[1].rows |
| 209 | + |
| 210 | + local header_row_count = tonumber(div.attr.attributes['header-rows']) or |
| 211 | + (class == 'list-table' and 1 or 0) |
| 212 | + div.attr.attributes['header-rows'] = nil |
| 213 | + |
| 214 | + local header_col_count = tonumber(div.attr.attributes['header-cols']) or 0 |
| 215 | + div.attr.attributes['header-cols'] = nil |
| 216 | + |
| 217 | + local column_count = 0 |
| 218 | + for i = 1, #row_cells(rows[1] or {}) do |
| 219 | + column_count = column_count + row_cells(rows[1])[i].col_span |
| 220 | + end |
| 221 | + |
| 222 | + local colspecs = get_colspecs(div.attr.attributes, column_count) |
| 223 | + local thead_rows = {} |
| 224 | + for i = 1, header_row_count do |
| 225 | + table.insert(thead_rows, table.remove(rows, 1)) |
| 226 | + end |
| 227 | + |
| 228 | + local new_bodies = {} |
| 229 | + for _, body in ipairs(bodies) do |
| 230 | + if #body.rows > 0 then |
| 231 | + table.insert(new_bodies, new_table_body(body.rows, body.attr, |
| 232 | + header_col_count)) |
| 233 | + end |
| 234 | + -- XXX this should be a body property |
| 235 | + header_col_count = 0 |
| 236 | + end |
| 237 | + |
| 238 | + return pandoc.Table( |
| 239 | + {long = caption, short = {}}, |
| 240 | + colspecs, |
| 241 | + pandoc.TableHead(thead_rows), |
| 242 | + new_bodies, |
| 243 | + pandoc.TableFoot(), |
| 244 | + div.attr |
| 245 | + ) |
| 246 | +end |
| 247 | + |
| 248 | +return { |
| 249 | + list_table_filter = function() |
| 250 | + return {Div = process} |
| 251 | + end |
| 252 | +} |
0 commit comments