Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/changelog-1.9.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (<https://nfpm.goreleaser.com/>) 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.
3 changes: 2 additions & 1 deletion src/resources/filters/modules/import_all.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
252 changes: 252 additions & 0 deletions src/resources/filters/modules/listtable.lua
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 5 additions & 0 deletions src/resources/filters/normalize/astpipeline.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions src/resources/filters/normalize/flags.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion tests/docs/crossrefs/tables.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,22 @@ See @tbl-letters.
Main Caption
:::

See @tbl-panel for details, especially @tbl-second.
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.
59 changes: 59 additions & 0 deletions tests/docs/smoke-all/table/list_table.qmd
Original file line number Diff line number Diff line change
@@ -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_CHROMIUM_HEADLESS_MODE>` . 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`.

:::
Loading