diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 7c00fa71624..a89af56dabd 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -37,3 +37,4 @@ All changes included in 1.9: ## Other fixes and improvements - ([#13402](https://github.com/quarto-dev/quarto-cli/issues/13402)): `nfpm` () is now used to create the `.deb` package, and new `.rpm` package. Both Linux packages are also now built for `x86_64` (`amd64`) and `aarch64` (`arm64`) architectures. +- ([#13528](https://github.com/quarto-dev/quarto-cli/pull/13528)): Adds support for table specification using nested lists and the `list-table` class. diff --git a/src/resources/filters/modules/import_all.lua b/src/resources/filters/modules/import_all.lua index 4acfe483873..ce87e0c4897 100644 --- a/src/resources/filters/modules/import_all.lua +++ b/src/resources/filters/modules/import_all.lua @@ -20,7 +20,8 @@ _quarto.modules = { scope = require("modules/scope"), string = require("modules/string"), tablecolwidths = require("modules/tablecolwidths"), - typst = require("modules/typst") + typst = require("modules/typst"), + listtable = require("modules/listtable") } quarto.brand = _quarto.modules.brand diff --git a/src/resources/filters/modules/listtable.lua b/src/resources/filters/modules/listtable.lua new file mode 100644 index 00000000000..d6a038de8f0 --- /dev/null +++ b/src/resources/filters/modules/listtable.lua @@ -0,0 +1,252 @@ +-- lua filter for RST-like list-tables in Markdown. +-- Copyright (C) 2021 Martin Fischer, released under MIT license + +-- Changes for use in Quarto are +-- Copyright (C) 2025 Posit Software, PBC + +-- Get the list of cells in a row. +local row_cells = function (row) return row.cells or {} end + +local alignments = { + d = 'AlignDefault', + l = 'AlignLeft', + r = 'AlignRight', + c = 'AlignCenter' +} + +-- This is like assert() but it can take a Block or Blocks 'where' argument +-- and will output the corresponding markdown (truncated at 1024 characters). +local function assert_(assertion, message, where) + message = message or 'assertion failed!' + if not assertion then + local extra = '' + if where then + local blocks = pandoc.Blocks(where) + local markdown = pandoc.write(pandoc.Pandoc(blocks), 'markdown') + extra = ' at\n' .. markdown:sub(1, 1024) .. + (#markdown > 1024 and '...' or '') + end + error(message .. extra, 2) + end +end + +-- Skip data-pos Divs inserted by the sourcepos extension +local function block_skip_data_pos(block) + if (block.t == "Div" and block.attr.attributes["data-pos"]) then + block = block.content[1] + end + return block +end + +local function blocks_skip_data_pos(blocks) + local new_blocks = {} + for _, block in ipairs(blocks) do + table.insert(new_blocks, block_skip_data_pos(block)) + end + return new_blocks +end + +local function get_colspecs(div_attributes, column_count) + -- list of (align, width) pairs + local colspecs = {} + + for i = 1, column_count do + table.insert(colspecs, {pandoc.AlignDefault, nil}) + end + + if div_attributes.aligns then + local i = 1 + for a in div_attributes.aligns:gmatch('[^,]+') do + assert_(alignments[a] ~= nil, + "unknown column alignment " .. tostring(a)) + colspecs[i][1] = alignments[a] + i = i + 1 + end + div_attributes.aligns = nil + end + + if div_attributes.widths then + local total = 0 + local widths = {} + for w in div_attributes.widths:gmatch('[^,]+') do + table.insert(widths, tonumber(w)) + total = total + tonumber(w) + end + for i = 1, column_count do + colspecs[i][2] = widths[i] / total + end + div_attributes.widths = nil + end + + return colspecs +end + +local function new_table_body(rows, attr, header_col_count) + attr = attr or {} + return { + attr = attr, + body = rows, + head = {}, + row_head_columns = header_col_count + } +end + +local function new_cell(contents) + local attr = {} + local colspan = 1 + local rowspan = 1 + local align = pandoc.AlignDefault + + contents = blocks_skip_data_pos(contents) + + -- At the time of writing this Pandoc does not support attributes + -- on list items, so we use empty spans as a workaround. + if contents[1] and contents[1].content then + if contents[1].content[1] and contents[1].content[1].t == "Span" then + if #contents[1].content[1].content == 0 then + attr = contents[1].content[1].attr + table.remove(contents[1].content, 1) + colspan = attr.attributes.colspan or 1 + attr.attributes.colspan = nil + rowspan = attr.attributes.rowspan or 1 + attr.attributes.rowspan = nil + align = alignments[attr.attributes.align] or pandoc.AlignDefault + attr.attributes.align = nil + end + end + end + + return pandoc.Cell(contents, align, rowspan, colspan, attr) +end + +local function process(div) + if (div.attr.classes[1] ~= "list-table" and + div.attr.classes[1] ~= "list-table-body") then return nil end + local class = div.attr.classes[1] + table.remove(div.attr.classes, 1) + + if #div.content == 0 then return nil end + + local content = blocks_skip_data_pos(div.content) + + local caption = {} + if content[1].t == "Para" then + local para = table.remove(content, 1) + caption = {pandoc.Plain(para.content)} + end + + if #content == 0 then return nil end + + assert_(content[1].t == "BulletList", + "expected bullet list, found " .. content[1].t, content[1]) + local list = content[1] + + -- rows points to the current body's rows + local bodies = {attr=nil, {rows={}}} + local rows = bodies[#bodies].rows + + for i = 1, #list.content do + local attr = nil + local items = list.content[i] + if (#items > 1) then + local item = block_skip_data_pos(items[1]) + assert_(item.content, "expected list item to have row attrs", + item) + assert_(#item.content == 1, "expected row attrs to contain " .. + "only one inline", item.content) + assert_(item.content[1].t == "Span", "expected row attrs to " .. + "contain a span", item.content[1]) + assert_(#item.content[1].content == 0, "expected row attrs " .. + "span to be empty", item.content[1]) + attr = item.content[1].attr + table.remove(items, 1) + end + + assert_(#items == 1, "expected item to contain only one block", items) + + local item = block_skip_data_pos(items[1]) + if (item.t ~= 'Table') then + assert_(item.t == "BulletList", "expected bullet list, found " .. + item.t, item) + local cells = {} + for _, cell_content in pairs(item.content) do + table.insert(cells, new_cell(cell_content)) + end + local row = pandoc.Row(cells, attr) + table.insert(rows, row) + + else + local tab = item + -- XXX is there a better way to check that there's no caption? + assert_((not tab.caption.long or #tab.caption.long == 0) and + (not tab.caption.short or #tab.caption.short == 0), + "table bodies can't have captions (they'd be " .. + "ignored)", tab) + -- XXX would have to check against default colspecs to know whether + -- any have been defined? + -- assert_(#tab.colspecs == 0, "table bodies can't (yet) have " .. + -- "column specs", tab) + -- XXX should allow empty headers; this can happen with pipe tables + -- assert_(not tab.head or #tab.head.rows == 0, + -- "table bodies can't (yet) have headers", tab) + assert_(#tab.bodies == 1, "table bodies can't contain other " .. + "table bodies", tab) + + if #rows > 0 then + table.insert(bodies, {attr=nil, rows={}}) + rows = bodies[#bodies].rows + end + + bodies[#bodies].attr = tab.attr + for _, row in ipairs(tab.bodies[1].body) do + table.insert(rows, row) + end + end + end + + -- switch back to the first body + rows = bodies[1].rows + + local header_row_count = tonumber(div.attr.attributes['header-rows']) or + (class == 'list-table' and 1 or 0) + div.attr.attributes['header-rows'] = nil + + local header_col_count = tonumber(div.attr.attributes['header-cols']) or 0 + div.attr.attributes['header-cols'] = nil + + local column_count = 0 + for i = 1, #row_cells(rows[1] or {}) do + column_count = column_count + row_cells(rows[1])[i].col_span + end + + local colspecs = get_colspecs(div.attr.attributes, column_count) + local thead_rows = {} + for i = 1, header_row_count do + table.insert(thead_rows, table.remove(rows, 1)) + end + + local new_bodies = {} + for _, body in ipairs(bodies) do + if #body.rows > 0 then + table.insert(new_bodies, new_table_body(body.rows, body.attr, + header_col_count)) + end + -- XXX this should be a body property + header_col_count = 0 + end + + return pandoc.Table( + {long = caption, short = {}}, + colspecs, + pandoc.TableHead(thead_rows), + new_bodies, + pandoc.TableFoot(), + div.attr + ) +end + +return { + list_table_filter = function() + return {Div = process} + end +} diff --git a/src/resources/filters/normalize/astpipeline.lua b/src/resources/filters/normalize/astpipeline.lua index b26e280bae8..1065d705620 100644 --- a/src/resources/filters/normalize/astpipeline.lua +++ b/src/resources/filters/normalize/astpipeline.lua @@ -323,6 +323,11 @@ function quarto_ast_pipeline() end return { + { name = "astpipeline-process-list-tables", + filter = _quarto.modules.listtable.list_table_filter(), + traverser = 'jog', + }, + { name = "astpipeline-process-tables", filter = astpipeline_process_tables(), traverser = 'jog', diff --git a/src/resources/filters/normalize/flags.lua b/src/resources/filters/normalize/flags.lua index 245dd571153..5ba783eb6a7 100644 --- a/src/resources/filters/normalize/flags.lua +++ b/src/resources/filters/normalize/flags.lua @@ -92,6 +92,10 @@ function compute_flags() flags.has_hidden = true end + if node.attr.classes:find("list-table") then + flags.has_list_tables = true + end + if node.attr.classes:find("cell") then -- cellcleanup.lua flags.has_output_cells = true diff --git a/tests/docs/crossrefs/tables.qmd b/tests/docs/crossrefs/tables.qmd index 7dc1d002e91..1ff018d517c 100644 --- a/tests/docs/crossrefs/tables.qmd +++ b/tests/docs/crossrefs/tables.qmd @@ -36,4 +36,22 @@ See @tbl-letters. Main Caption ::: -See @tbl-panel for details, especially @tbl-second. \ No newline at end of file +See @tbl-panel for details, especially @tbl-second. + +## List tables + +::: {#tbl-list .list-table} + +This has a caption. {tbl-colwidths=[20,40,40]} + +* - Row 1, Col 1 + - Row 1, Col 2 + - Row 1, Col 3 + +* - Row 2, Col 1 + - Row 2, Col 2 + - Row 2, Col 3 + +::: + +see @tbl-list. \ No newline at end of file diff --git a/tests/docs/smoke-all/table/list_table.qmd b/tests/docs/smoke-all/table/list_table.qmd new file mode 100644 index 00000000000..01b63d0608c --- /dev/null +++ b/tests/docs/smoke-all/table/list_table.qmd @@ -0,0 +1,59 @@ +--- +title: List tables in Quarto +_quarto: + tests: + html: + ensureHtmlElements: + - ["table"] + - ["div.list-table"] +--- + +::: {.list-table widths="0.070833333333333,0.92916666666667" header-rows="1"} + +* * Variable + * Description + +* * `QUARTO_R` + * Explicit path to the version of `Rscript` to be used by the `knitr` engine and `quarto run *.R` command. + +* * `QUARTO_PYTHON` + * Explicit path to the version of `python` to be used by the `jupyter` engine and `quarto run *.py` command. + +* * `QUARTO_JULIA` + * Explicit path to the version of `julia` to be used by the `julia` engine. + +* * `QUARTO_VERSION_REQUIREMENT` + * A [`semver`](https://semver.org/) string describing the Quarto version requested by the environment. If this check fails, Quarto will not run. + +* * `QUARTO_KNITR_RSCRIPT_ARGS` + + * Comma separated list of command line argument to pass to `Rscript` started by Quarto when rendering with `knitr` engine, e.g. + + ``` + QUARTO_KNITR_RSCRIPT_ARGS="--no-init-file,--max-connections=258" + ``` + +* * `QUARTO_TEXLIVE_BINPATH` + * Explicit path to the TeX Live binaries to be passed to `tlmgr option sys_bin` used when setting `tlmgr` and related to `PATH` using `tlmgr add path` . By default, Quarto looks in known places. + +* * `QUARTO_CHROMIUM` + * Explicit path to binary to use for chrome headless. Quarto uses [Chrome Devtools Protocol](https://chromedevtools.github.io/devtools-protocol/) to do screenshot of HTML diagrams for PDF insertion. The binary must be compatible with this protocol. (e.g. Chrome, Chromium, Chrome Headless Shell, Edge, …) + +* * `QUARTO_CHROMIUM_HEADLESS_MODE` + * Used for adaption of the `--headless` mode used with `QUARTO_CHROMIUM` binary. Set to `"none"` for `--headless` , or to `"old"` or `"new"` to pass as argument, e.g. `--headless=` . Quarto 1.6 sets `"old"` as default, which works from Chrome 112 to 131. Starting Quarto 1.7.13, `"none"` is the default as [Chrome 132 removed old headless mode](https://developer.chrome.com/blog/removing-headless-old-from-chrome). + +* * `QUARTO_LOG` + + `QUARTO_LOG_LEVEL` + + `QUARTO_LOG_FORMAT` + + * Those variables controls the logging behavior: + + * `QUARTO_LOG` is the same as using `--log` at command line. It is used to set the path to the log file + + * `QUARTO_LOG_LEVEL` is the same as using `--log-level` at command line. It is used to set the max level that will be log. Possible values are `DEBUG`, `INFO`(default), `WARNING`, and `ERROR`. + + * `QUARTO_LOG_FORMAT` is the same as using `--log-format` at command line. It is used to set the format for the log. Possible values are `plain` (default) and `json-stream`. + +:::