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`.
+
+:::