Skip to content

Commit 1405db6

Browse files
authored
Merge pull request #13528 from quarto-dev/feature/list-tables
Feature/list tables
2 parents 56eb16a + 581057a commit 1405db6

File tree

7 files changed

+342
-2
lines changed

7 files changed

+342
-2
lines changed

news/changelog-1.9.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ All changes included in 1.9:
3737
## Other fixes and improvements
3838

3939
- ([#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.
40+
- ([#13528](https://github.com/quarto-dev/quarto-cli/pull/13528)): Adds support for table specification using nested lists and the `list-table` class.

src/resources/filters/modules/import_all.lua

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ _quarto.modules = {
2020
scope = require("modules/scope"),
2121
string = require("modules/string"),
2222
tablecolwidths = require("modules/tablecolwidths"),
23-
typst = require("modules/typst")
23+
typst = require("modules/typst"),
24+
listtable = require("modules/listtable")
2425
}
2526

2627
quarto.brand = _quarto.modules.brand
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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+
}

src/resources/filters/normalize/astpipeline.lua

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,11 @@ function quarto_ast_pipeline()
323323
end
324324

325325
return {
326+
{ name = "astpipeline-process-list-tables",
327+
filter = _quarto.modules.listtable.list_table_filter(),
328+
traverser = 'jog',
329+
},
330+
326331
{ name = "astpipeline-process-tables",
327332
filter = astpipeline_process_tables(),
328333
traverser = 'jog',

src/resources/filters/normalize/flags.lua

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ function compute_flags()
9292
flags.has_hidden = true
9393
end
9494

95+
if node.attr.classes:find("list-table") then
96+
flags.has_list_tables = true
97+
end
98+
9599
if node.attr.classes:find("cell") then
96100
-- cellcleanup.lua
97101
flags.has_output_cells = true

tests/docs/crossrefs/tables.qmd

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,22 @@ See @tbl-letters.
3636
Main Caption
3737
:::
3838

39-
See @tbl-panel for details, especially @tbl-second.
39+
See @tbl-panel for details, especially @tbl-second.
40+
41+
## List tables
42+
43+
::: {#tbl-list .list-table}
44+
45+
This has a caption. {tbl-colwidths=[20,40,40]}
46+
47+
* - Row 1, Col 1
48+
- Row 1, Col 2
49+
- Row 1, Col 3
50+
51+
* - Row 2, Col 1
52+
- Row 2, Col 2
53+
- Row 2, Col 3
54+
55+
:::
56+
57+
see @tbl-list.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
title: List tables in Quarto
3+
_quarto:
4+
tests:
5+
html:
6+
ensureHtmlElements:
7+
- ["table"]
8+
- ["div.list-table"]
9+
---
10+
11+
::: {.list-table widths="0.070833333333333,0.92916666666667" header-rows="1"}
12+
13+
* * Variable
14+
* Description
15+
16+
* * `QUARTO_R`
17+
* Explicit path to the version of `Rscript` to be used by the `knitr` engine and `quarto run *.R` command.
18+
19+
* * `QUARTO_PYTHON`
20+
* Explicit path to the version of `python` to be used by the `jupyter` engine and `quarto run *.py` command.
21+
22+
* * `QUARTO_JULIA`
23+
* Explicit path to the version of `julia` to be used by the `julia` engine.
24+
25+
* * `QUARTO_VERSION_REQUIREMENT`
26+
* A [`semver`](https://semver.org/) string describing the Quarto version requested by the environment. If this check fails, Quarto will not run.
27+
28+
* * `QUARTO_KNITR_RSCRIPT_ARGS`
29+
30+
* Comma separated list of command line argument to pass to `Rscript` started by Quarto when rendering with `knitr` engine, e.g.
31+
32+
```
33+
QUARTO_KNITR_RSCRIPT_ARGS="--no-init-file,--max-connections=258"
34+
```
35+
36+
* * `QUARTO_TEXLIVE_BINPATH`
37+
* 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.
38+
39+
* * `QUARTO_CHROMIUM`
40+
* 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, …)
41+
42+
* * `QUARTO_CHROMIUM_HEADLESS_MODE`
43+
* 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).
44+
45+
* * `QUARTO_LOG`
46+
47+
`QUARTO_LOG_LEVEL`
48+
49+
`QUARTO_LOG_FORMAT`
50+
51+
* Those variables controls the logging behavior:
52+
53+
* `QUARTO_LOG` is the same as using `--log` at command line. It is used to set the path to the log file
54+
55+
* `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`.
56+
57+
* `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`.
58+
59+
:::

0 commit comments

Comments
 (0)