From 1260729ea8c6134049eba00be5a847df47f5f884 Mon Sep 17 00:00:00 2001 From: enoch Date: Tue, 20 Oct 2026 12:48:06 +0800 Subject: [PATCH 1/5] fix: use Pandoc floats for ordinary two-column tables --- configuration | 2 +- news/changelog-1.11.md | 6 ++ src/command/check/check.ts | 2 +- .../filters/customnodes/floatreftarget.lua | 4 ++ src/resources/filters/main.lua | 1 + .../filters/quarto-pre/table-classes.lua | 59 +++++++++++++++++++ tests/docs/crossrefs/tables-float.qmd | 17 ++++++ .../docs/crossrefs/tables-twocolumn-list.qmd | 22 +++++++ tests/docs/crossrefs/tables-twocolumn.qmd | 39 ++++++++++++ tests/smoke/crossref/tables-float.test.ts | 42 +++++++++++++ 10 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 tests/docs/crossrefs/tables-float.qmd create mode 100644 tests/docs/crossrefs/tables-twocolumn-list.qmd create mode 100644 tests/docs/crossrefs/tables-twocolumn.qmd create mode 100644 tests/smoke/crossref/tables-float.test.ts diff --git a/configuration b/configuration index 7cf1b64e29c..7ca25377400 100644 --- a/configuration +++ b/configuration @@ -13,7 +13,7 @@ export DENO=v2.7.14 # TODO figure out where 0.1.41 apple silicon libs are available export DENO_DOM=v0.1.41-alpha-artifacts -export PANDOC=3.10 +export PANDOC=3.10.1 export DARTSASS=1.101.0 export ESBUILD=0.28.1 export TYPST=0.15.1 diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index e5a1c1f3db8..7cb04544d92 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -11,6 +11,8 @@ All changes included in 1.11: ## Dependencies +- Update Pandoc to 3.10.1. + - ([#15043](https://github.com/quarto-dev/quarto-cli/issues/15043)): Update the OJS runtime to acorn 8.17, Observable Inspector 5.0.1, and Observable Inputs 0.10.6. `FileAttachment(...).mimeType` now reports `text/javascript` instead of `application/javascript` for `.js` and `.mjs` files. ## Accessibility @@ -39,6 +41,10 @@ All changes included in 1.11: ## Formats +### `pdf` + +- ([#14846](https://github.com/quarto-dev/quarto-cli/issues/14846)): Use Pandoc's floating tables for ordinary Markdown tables in documents with `classoption: twocolumn`, including tables with cross-references. Single-column documents retain `longtable` by default and can opt into floating tables with `.float`. Respect `tbl-pos` for floating cross-referenced tables unless `latex-placement` is explicitly set. Panel, subtable, margin, and full-width layout handling is unchanged. + ### All Formats - ([#14772](https://github.com/quarto-dev/quarto-cli/issues/14772)): Custom `language` keys now reach templates through `$quarto.language.$`. A key that Quarto does not ship passed schema validation and was merged over the defaults, then discarded before templates saw it, so it expanded to an empty string with no warning. (author: @mcanouil) diff --git a/src/command/check/check.ts b/src/command/check/check.ts index b66ab7e5f06..19cb92f7f14 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -300,7 +300,7 @@ async function checkVersions(conf: CheckConfiguration) { // file is in an awkward format and it is not packaged // with our installers const versionConstraints: [string | undefined, string, string][] = [ - [pandocVersion, "3.10.0", "Pandoc"], + [pandocVersion, "3.10.1", "Pandoc"], [sassVersion, "1.101.0", "Dart Sass"], [denoVersion, "2.7.14", "Deno"], [typstVersion, "0.15.1", "Typst"], diff --git a/src/resources/filters/customnodes/floatreftarget.lua b/src/resources/filters/customnodes/floatreftarget.lua index eaa0494e66f..598f4d77eaf 100644 --- a/src/resources/filters/customnodes/floatreftarget.lua +++ b/src/resources/filters/customnodes/floatreftarget.lua @@ -336,6 +336,10 @@ end, function(float) caption_cmd_name = "caption" float.content.caption.long = float.caption_long float.content.attr = pandoc.Attr(float.identifier, float.classes or {}, float.attributes or {}) + if float.content.classes:includes("float") and figPos and + float.content.attributes["latex-placement"] == nil then + float.content.attributes["latex-placement"] = figPos + end return float.content end diff --git a/src/resources/filters/main.lua b/src/resources/filters/main.lua index 3d4603344de..3f454b00793 100644 --- a/src/resources/filters/main.lua +++ b/src/resources/filters/main.lua @@ -638,6 +638,7 @@ local quarto_finalize_filters = { } local quarto_layout_filters = { + { name = "layout-latex-table-floats", filter = latex_table_floats() }, { name = "manuscript filtering", filter = manuscript(), traverser = 'jog', diff --git a/src/resources/filters/quarto-pre/table-classes.lua b/src/resources/filters/quarto-pre/table-classes.lua index 9e9067420a1..4da75780337 100644 --- a/src/resources/filters/quarto-pre/table-classes.lua +++ b/src/resources/filters/quarto-pre/table-classes.lua @@ -99,3 +99,62 @@ function table_classes() } end + +-- Use Pandoc's floating tables for ordinary tables in a two-column document. +-- Panels, subtables, and margin/full-width tables have their own renderers. +function latex_table_floats() + if not _quarto.format.isLatexOutput() then + return {} + end + return { + Pandoc = function(doc) + local twocolumn = false + local options = doc.meta.classoption or {} + if pandoc.utils.type(options) == "Inlines" or type(options) == "string" then + options = { options } + end + for _, value in ipairs(options) do + for option in pandoc.utils.stringify(value):gmatch("[^,%s]+") do + if option == "twocolumn" then + twocolumn = true + elseif option == "onecolumn" then + twocolumn = false + end + end + end + if not twocolumn then + return nil + end + + local function add_float(el) + if el.classes:find_if(isStarEnv) or el.classes:find_if(isMarginEnv) then + return nil + end + if not el.classes:includes("float") then + el.classes:insert("float") + end + return el + end + return _quarto.ast.walk(doc, { + traverse = "topdown", + Div = function(div) + if attr_requires_panel_layout(div.attr) or + div.classes:find_if(isStarEnv) or div.classes:find_if(isMarginEnv) then + return nil, false + end + end, + FloatRefTarget = function(float) + if not float.parent_id and ref_type_from_float(float) == "tbl" and + float.content and float.content.t == "Table" then + add_float(float) + end + -- Other floats (including panels) must not acquire nested floats. + return float, false + end, + Table = function(tbl) + return add_float(tbl), false + end + }) + end + } +end diff --git a/tests/docs/crossrefs/tables-float.qmd b/tests/docs/crossrefs/tables-float.qmd new file mode 100644 index 00000000000..52deb214819 --- /dev/null +++ b/tests/docs/crossrefs/tables-float.qmd @@ -0,0 +1,17 @@ +--- +documentclass: article +--- + +See @tbl-long and @tbl-float. + +| Name | Value | +|------|------:| +| Long | 1 | + +: Default caption {#tbl-long} + +| Name | Value | +|------|------:| +| Float | 2 | + +: Floating caption {#tbl-float .float tbl-pos="t"} diff --git a/tests/docs/crossrefs/tables-twocolumn-list.qmd b/tests/docs/crossrefs/tables-twocolumn-list.qmd new file mode 100644 index 00000000000..919bf61f106 --- /dev/null +++ b/tests/docs/crossrefs/tables-twocolumn-list.qmd @@ -0,0 +1,22 @@ +--- +documentclass: article +classoption: [a4paper, twocolumn] +--- + +| Name | Value | +|------|------:| +| List option | 1 | + +: List caption {#tbl-list} + +::: {layout-ncol=2} + +| Name | Value | +|------|------:| +| Left | 2 | + +| Name | Value | +|------|------:| +| Right | 3 | + +::: diff --git a/tests/docs/crossrefs/tables-twocolumn.qmd b/tests/docs/crossrefs/tables-twocolumn.qmd new file mode 100644 index 00000000000..76c15dd172a --- /dev/null +++ b/tests/docs/crossrefs/tables-twocolumn.qmd @@ -0,0 +1,39 @@ +--- +documentclass: article +classoption: twocolumn +--- + +See @tbl-first, @tbl-second, and @tbl-panel (especially @tbl-child). + +| Name | Value | +|------|------:| +| Plain | 0 | + +| Name | Value | +|------|------:| +| First | 1 | + +: First caption {#tbl-first tbl-pos="t"} + +| Name | Value | +|------|------:| +| Second | 2 | + +: Second caption {#tbl-second .float latex-placement="b" tbl-pos="t"} + +::: {#tbl-panel layout-ncol=2} + +| Name | Value | +|------|------:| +| Child | 3 | + +: Child caption {#tbl-child .float} + +| Name | Value | +|------|------:| +| Other | 4 | + +: Other caption {#tbl-other} + +Panel caption +::: diff --git a/tests/smoke/crossref/tables-float.test.ts b/tests/smoke/crossref/tables-float.test.ts new file mode 100644 index 00000000000..f6bdf01e81c --- /dev/null +++ b/tests/smoke/crossref/tables-float.test.ts @@ -0,0 +1,42 @@ +/* + * tables-float.test.ts + * + * Copyright (C) 2026 Posit Software, PBC + */ +import { docs } from "../../utils.ts"; +import { ensurePdfRegexMatches } from "../../verify.ts"; +import { renderVerifyLatexOutput, testRender } from "../render/render.ts"; + +renderVerifyLatexOutput(docs("crossrefs/tables-twocolumn.qmd"), [ + /\\begin\{table\}\[t\][^]*?\\caption\{First caption\}\\label\{tbl-first\}/, + /\\begin\{table\}\[b\][^]*?\\caption\{Second caption\}\\label\{tbl-second\}/, + /Table~\\ref\{tbl-first\}/, + /Table~\\ref\{tbl-second\}/, + /\\subcaption\{\\label\{tbl-child\}Child caption\}/, +], [ + /\\begin\{longtable\}/, + /\\begin\{table\}(?:(?!\\end\{table\})[^])*\\begin\{table\}/, +]); + +renderVerifyLatexOutput(docs("crossrefs/tables-float.qmd"), [ + /\\begin\{longtable\}[^]*?\\caption\{Default caption\}\\label\{tbl-long\}/, + /\\begin\{table\}\[t\][^]*?\\caption\{Floating caption\}\\label\{tbl-float\}/, + /Table~\\ref\{tbl-long\}/, + /Table~\\ref\{tbl-float\}/, +]); + +renderVerifyLatexOutput(docs("crossrefs/tables-twocolumn-list.qmd"), [ + /\\begin\{table\}[^]*?\\caption\{List caption\}\\label\{tbl-list\}/, + /\\begin\{minipage\}[^]*?\\begin\{longtable\}/, +], [ + /\\begin\{minipage\}[^]*?\\begin\{table\}/, +]); + +testRender(docs("crossrefs/tables-twocolumn.qmd"), "pdf", true, [ + ensurePdfRegexMatches(docs("crossrefs/tables-twocolumn.pdf"), [ + /Table 1: First caption/, + /Table 2: Second caption/, + /Table 3: Panel caption/, + /See Table 1, Table 2, and Table 3 \(especially Table 3a\)/, + ], [/\?\?/]), +]); From 1380bdcf262b4712c97e117b319963923c7a3f3a Mon Sep 17 00:00:00 2001 From: enoch Date: Tue, 20 Oct 2026 13:04:35 +0800 Subject: [PATCH 2/5] fix: preserve native table layouts in two-column PDFs --- news/changelog-1.11.md | 6 +- .../filters/customnodes/floatreftarget.lua | 20 ++- src/resources/filters/layout/latex.lua | 130 ++++++++++++------ src/resources/filters/layout/meta.lua | 28 +++- src/resources/filters/quarto-post/latex.lua | 20 ++- tests/docs/crossrefs/tables-float-layouts.qmd | 61 ++++++++ tests/docs/crossrefs/tables-float-margin.qmd | 25 ++++ tests/docs/crossrefs/tables-float-switch.qmd | 32 +++++ tests/smoke/crossref/tables-float.test.ts | 47 ++++++- 9 files changed, 310 insertions(+), 59 deletions(-) create mode 100644 tests/docs/crossrefs/tables-float-layouts.qmd create mode 100644 tests/docs/crossrefs/tables-float-margin.qmd create mode 100644 tests/docs/crossrefs/tables-float-switch.qmd diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 7cb04544d92..c1155e06db3 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -41,10 +41,6 @@ All changes included in 1.11: ## Formats -### `pdf` - -- ([#14846](https://github.com/quarto-dev/quarto-cli/issues/14846)): Use Pandoc's floating tables for ordinary Markdown tables in documents with `classoption: twocolumn`, including tables with cross-references. Single-column documents retain `longtable` by default and can opt into floating tables with `.float`. Respect `tbl-pos` for floating cross-referenced tables unless `latex-placement` is explicitly set. Panel, subtable, margin, and full-width layout handling is unchanged. - ### All Formats - ([#14772](https://github.com/quarto-dev/quarto-cli/issues/14772)): Custom `language` keys now reach templates through `$quarto.language.$`. A key that Quarto does not ship passed schema validation and was merged over the defaults, then discarded before templates saw it, so it expanded to an empty string with no warning. (author: @mcanouil) @@ -70,6 +66,8 @@ All changes included in 1.11: ### `pdf` +- ([#14846](https://github.com/quarto-dev/quarto-cli/issues/14846)): Use Pandoc's floating tables for ordinary Markdown tables in documents with `classoption: twocolumn`, including tables with cross-references. Single-column documents retain `longtable` by default and can opt into floating tables with `.float`. Respect `tbl-pos` unless `latex-placement` is explicitly set. Render full-width, margin, bottom-captioned, and panel tables without `longtable`, retaining Pandoc's widths, spans, citations, and footnotes. Preserve native double-column `table*` and `figure*` environments when loading `sidenotes`, including documents that switch columns in the body. + - ([#14982](https://github.com/quarto-dev/quarto-cli/issues/14982)): Fix a footnote in document metadata, such as `title` or `author`, making PDF and Typst output shift heading levels, which turned the first level-1 heading into the document title. - ([#15027](https://github.com/quarto-dev/quarto-cli/issues/15027)): Fix PDF rendering with regional German variants such as `lang: de-CH` failing when German hyphenation patterns are not installed, instead of installing them automatically. - ([#15035](https://github.com/quarto-dev/quarto-cli/issues/15035)): Fix automatic installation of missing hyphenation patterns for the pinned German patterns (`ngerman-x-latest`), for several languages at once, and for languages loaded with `polyglossia`. diff --git a/src/resources/filters/customnodes/floatreftarget.lua b/src/resources/filters/customnodes/floatreftarget.lua index 598f4d77eaf..e5f989bdff1 100644 --- a/src/resources/filters/customnodes/floatreftarget.lua +++ b/src/resources/filters/customnodes/floatreftarget.lua @@ -326,7 +326,8 @@ end, function(float) return {} end caption_cmd_name = "subcaption" - elseif float.content.t == "Table" and float_type == "tbl" then -- float.parent_id is nil here + elseif float.content.t == "Table" and float_type == "tbl" and + figEnv == "table" and capLoc == "top" then -- float.parent_id is nil here -- special-case the situation where the figure is Table and the content is Table -- -- just return the table itself with the caption inside the table @@ -343,6 +344,16 @@ end, function(float) return float.content end + -- Native tables in full-width/margin floats, or with bottom captions, need + -- Quarto's environment and caption rather than Pandoc's default longtable. + if not float.parent_id and float.content.t == "Table" and float_type == "tbl" then + figPos = float.attributes["latex-placement"] or figPos + if figEnv == "marginfigure" then + figEnv = "margintable" + end + float.content = latexTabular(float.content) + end + local fig_scap = attribute(float, kFigScap, nil) if fig_scap then fig_scap = pandoc.Span(markdownToInlines(fig_scap)) @@ -404,6 +415,9 @@ end, function(float) end end, Table = function(tbl) + if tbl.classes:includes("float") then + return latexTabular(tbl) + end local cites = pandoc.List({}) local guid_id = global_table_guid_id local uuid = "85b77c8a-261c-4f58-9b04-f21c67e0a758" @@ -650,13 +664,13 @@ end, function(float) -- the panel layout code return figure_content else - return quarto.LatexEnvironment({ + return latexTableNoteEnvironment(quarto.LatexEnvironment({ name = figEnv, pos = figPos, content = _quarto.ast.walk(figure_content, { Image = drop_class("column-margin") }) - }) + })) end end) diff --git a/src/resources/filters/layout/latex.lua b/src/resources/filters/layout/latex.lua index 58e4dd9165e..671acc2a68d 100644 --- a/src/resources/filters/layout/latex.lua +++ b/src/resources/filters/layout/latex.lua @@ -8,14 +8,14 @@ end, function (layout) local rendered_panel = latexPanel(layout) local preamble = layout.preamble if preamble == nil then - return rendered_panel + return latexTableNoteEnvironment(rendered_panel) end local result = pandoc.Blocks({}) panel_insert_preamble(result, preamble) result:insert(rendered_panel) - return result + return latexTableNoteEnvironment(pandoc.Div(result)) end) _quarto.ast.add_renderer("PanelLayout", function(_) @@ -365,6 +365,16 @@ function latexCell(cell, vAlign, endOfRow, endOfTable) end + -- Unlabelled tables in panels also need a non-floating tabular. Otherwise + -- Pandoc emits longtable inside the minipage, which still fails in twocolumn. + if not isSubRef and tbl then + cell.content = _quarto.ast.walk(cell.content, { + Table = function(table) + return latexTabular(table, vAlign) + end + }) + end + -- convert to latex percent as necessary width = asLatexSize(width) @@ -444,47 +454,87 @@ function latexCell(cell, vAlign, endOfRow, endOfTable) end function latexTabular(tbl, vAlign) - - -- convert to simple table - tbl = pandoc.utils.to_simple_table(tbl) - - -- list of inlines - local tabular = pandoc.List() - - -- vertically align the minipage - local tabularVAlign = latexMinipageValign(vAlign) - - -- caption - if #tbl.caption > 0 then - latexAppend(tabular, "\\caption{") - tappend(tabular, tbl.caption) - latexAppend(tabular, "}\n") + -- Let Pandoc serialize the full Table, including widths, spans, and blocks. + -- Only remove its outer float: Quarto's layout owns the enclosing environment. + tbl = tbl:clone() + local has_notes = false + _quarto.ast.walk(tbl, {Note = function() has_notes = true end}) + local caption = quarto.utils.as_inlines(tbl.caption.long) + tbl.caption = pandoc.Caption() + tbl.identifier = "" + if not tbl.classes:includes("float") then + tbl.classes:insert("float") + end + quarto.doc.use_latex_package("multirow") + local content = latexWriteTable(tbl, function(text) + local opens, closes + text, opens = text:gsub("\\begin{table}%b[]%s*\\centering%s*", "", 1) + text, closes = text:gsub("\\end{tabular}%s*\\end{table}", "\\end{tabular}", 1) + assert(opens == 1 and closes == 1, "Could not unwrap Pandoc's floating table") + text = text:gsub("\\begin{tabular}", "\\begin{tabular}" .. latexMinipageValign(vAlign), 1) + return text + end) + if has_notes then + quarto.doc.use_latex_package("footnotehyper") + content = pandoc.Div({content}, pandoc.Attr("", {"quarto-table-notes"})) + end + if caption and #caption > 0 then + return pandoc.Div({ + quarto.LatexInlineCommand({name = "caption", arg = pandoc.Span(caption)}), + content + }) end - - -- header - local aligns = table.concat(tbl.aligns:map(latexTabularAlign), "") - latexAppend(tabular, "\\begin{tabular}" .. tabularVAlign .. "{" .. aligns .. "}\n") - latexAppend(tabular, "\\toprule\n") - - -- headers (optional) - local headers = latexTabularRow(tbl.headers) - if latexTabularRowHasContent(headers) then - latexTabularRowAppend(tabular, headers) - latexAppend(tabular, "\\midrule\n") + return content +end + +-- Footnote text emitted while constructing a float would otherwise be lost. +-- Collect it around the outermost float/panel and release it afterwards. +function latexTableNoteEnvironment(content) + local has_notes = false + _quarto.ast.walk(content, { + Div = function(div) + if div.classes:includes("quarto-table-notes") then + has_notes = true + end + end + }) + if has_notes then + return quarto.LatexEnvironment({name = "savenotes", content = content}) + end + return content +end + +-- Keep citations in the AST for citeproc/natbib processing even when a table +-- must be serialized before the document's writer runs. +function latexWriteTable(tbl, transform) + local cites = pandoc.List({}) + local uuid = "85b77c8a-261c-4f58-9b04-f21c67e0a758" + local first_id = global_table_guid_id + tbl = _quarto.ast.walk(tbl, { + Cite = function(cite) + cites:insert(cite) + return pandoc.Str(uuid .. "-" .. (first_id + #cites) .. "-" .. uuid) + end + }) + local text = pandoc.write(pandoc.Pandoc({tbl}), "latex") + if transform then + text = transform(text) end - - -- rows - for _,row in ipairs(tbl.rows) do - latexTabularRowAppend(tabular, latexTabularRow(row)) + local raw = pandoc.RawBlock("latex", text) + if #cites == 0 then + return raw end - - -- footer - latexAppend(tabular, "\\bottomrule\n") - latexAppend(tabular, "\\end{tabular}") - - -- return tabular - return pandoc.Para(tabular) - + global_table_guid_id = first_id + #cites + local result = pandoc.Blocks({}) + for i, cite in ipairs(cites) do + result:insert(_quarto.ast.make_scaffold(pandoc.Span, pandoc.Inlines({ + pandoc.RawInline("latex", "%quarto-define-uuid: " .. uuid .. "-" .. (first_id + i) .. "-" .. uuid .. "\n"), + cite, + pandoc.RawInline("latex", "\n%quarto-end-define-uuid\n") + }))) + end + result:insert(raw) + return pandoc.Div(result) end function latexTabularRow(row) diff --git a/src/resources/filters/layout/meta.lua b/src/resources/filters/layout/meta.lua index 9c68c296a7d..97a04591fe2 100644 --- a/src/resources/filters/layout/meta.lua +++ b/src/resources/filters/layout/meta.lua @@ -106,9 +106,31 @@ function layout_meta_inject_latex_packages() if (layoutState.hasColumns or marginReferences() or marginCitations()) and _quarto.format.isLatexOutput() then -- inject sidenotes package metaInjectLatex(meta, function(inject) - inject( - usePackage("sidenotes") - ) + -- sidenotes redefines table*/figure* as single-column floats widened + -- into the margin. In twocolumn documents these must remain native + -- double-column floats. Choose at use time, since journal styles may + -- switch columns in the document rather than via classoption. + inject([[ +\@ifpackageloaded{sidenotes}{}{% + \expandafter\let\expandafter\quarto@native@table\csname table*\endcsname + \expandafter\let\expandafter\quarto@native@endtable\csname endtable*\endcsname + \expandafter\let\expandafter\quarto@native@figure\csname figure*\endcsname + \expandafter\let\expandafter\quarto@native@endfigure\csname endfigure*\endcsname + \usepackage{sidenotes} + \expandafter\let\expandafter\quarto@margin@table\csname table*\endcsname + \expandafter\let\expandafter\quarto@margin@endtable\csname endtable*\endcsname + \expandafter\let\expandafter\quarto@margin@figure\csname figure*\endcsname + \expandafter\let\expandafter\quarto@margin@endfigure\csname endfigure*\endcsname + \expandafter\def\csname table*\endcsname{% + \if@twocolumn\expandafter\quarto@native@table\else\expandafter\quarto@margin@table\fi} + \expandafter\def\csname endtable*\endcsname{% + \if@twocolumn\quarto@native@endtable\else\quarto@margin@endtable\fi} + \expandafter\def\csname figure*\endcsname{% + \if@twocolumn\expandafter\quarto@native@figure\else\expandafter\quarto@margin@figure\fi} + \expandafter\def\csname endfigure*\endcsname{% + \if@twocolumn\quarto@native@endfigure\else\quarto@margin@endfigure\fi} +} +]]) inject( usePackage("marginnote") ) diff --git a/src/resources/filters/quarto-post/latex.lua b/src/resources/filters/quarto-post/latex.lua index 3febbfc6a7d..59fd9f7504c 100644 --- a/src/resources/filters/quarto-post/latex.lua +++ b/src/resources/filters/quarto-post/latex.lua @@ -192,13 +192,15 @@ function render_latex() end function handle_table_columns(table) - local useMargin = table.classes:find_if(isStarEnv) - if useMargin then - return { - beginColumnComment(), - table, - endColumnComment() - } + local full_width = table.classes:find_if(isStarEnv) + local in_margin = table.classes:find_if(isMarginEnv) + if full_width or in_margin then + noteHasColumns() + return latexTableNoteEnvironment(quarto.LatexEnvironment({ + name = in_margin and "margintable" or "table*", + pos = table.attributes["latex-placement"] or table.attributes["tbl-pos"], + content = latexTabular(table) + })) end if table.classes:includes("render-as-tabular") then return latexTabular(table) @@ -239,6 +241,7 @@ function render_latex() Block = found_something_else, Inline = found_something_else, Table = function(t) + t.classes:insert("column-margin") local result = handle_table_columns(t) found_table = true return result,false @@ -260,6 +263,9 @@ function render_latex() if f ~= nil then noteHasColumns() el.content = strip(el.content, f) + el.content = _quarto.ast.walk(el.content, { + Table = function(tbl) return latexTabular(tbl) end + }) tprepend(el.content, {pandoc.RawBlock("latex", "\\begin{figure*}[H]")}) tappend(el.content, {pandoc.RawBlock("latex", "\\end{figure*}")}) return el, false diff --git a/tests/docs/crossrefs/tables-float-layouts.qmd b/tests/docs/crossrefs/tables-float-layouts.qmd new file mode 100644 index 00000000000..9cd6df537c6 --- /dev/null +++ b/tests/docs/crossrefs/tables-float-layouts.qmd @@ -0,0 +1,61 @@ +--- +documentclass: article +classoption: twocolumn +references: + - id: doe2020 + type: article-journal + title: Example study + author: + - family: Doe + given: Jane + issued: + date-parts: [[2020]] +--- + +See @tbl-wide, @tbl-bottom, and @tbl-after. + ++----------------------+----------------------+ +| Category | Value | ++======================+======================+ +| Spanning both columns | ++----------------------+----------------------+ +| Row span | First [@doe2020] | ++ +----------------------+ +| | Second | ++----------------------+----------------------+ + +: Wide caption {#tbl-wide .column-page tbl-pos="t" tbl-cap-location="bottom" fig-scap="Short wide caption"} + +| Name | Value | +|------|------:| +| Bottom^[Table note text.] | 2 | + +: Bottom caption {#tbl-bottom .float tbl-cap-location="bottom" tbl-colwidths="[30,70]"} + +::: {layout-ncol=2} + +| Name | Value | +|------|------:| +| Left | 3 | + +| Name | Value | +|------|------:| +| Right | 4 | + +::: + +| Name | Value | +|------|------:| +| After | 5 | + +: After caption {#tbl-after} + +```{=latex} +\begin{table*}[t] +\ifdim\linewidth<\textwidth + \errmessage{Double-column table lost its full text width} +\fi +\centering +Native double-column table. +\end{table*} +``` diff --git a/tests/docs/crossrefs/tables-float-margin.qmd b/tests/docs/crossrefs/tables-float-margin.qmd new file mode 100644 index 00000000000..a8443aeb5df --- /dev/null +++ b/tests/docs/crossrefs/tables-float-margin.qmd @@ -0,0 +1,25 @@ +--- +documentclass: article +--- + +See @tbl-margin and @tbl-wide-onecolumn. + +| Name | Value | +|------|------:| +| Margin | 1 | + +: Margin caption {#tbl-margin .column-margin tbl-cap-location="bottom"} + +| Name | Value | +|------|------:| +| Wide | 2 | + +: Wide one-column caption {#tbl-wide-onecolumn .column-page} + +::: {.column-margin} + +| Name | Value | +|------|------:| +| Bare margin | 3 | + +::: diff --git a/tests/docs/crossrefs/tables-float-switch.qmd b/tests/docs/crossrefs/tables-float-switch.qmd new file mode 100644 index 00000000000..0dbf64997bd --- /dev/null +++ b/tests/docs/crossrefs/tables-float-switch.qmd @@ -0,0 +1,32 @@ +--- +documentclass: article +--- + +```{=latex} +\twocolumn +\begin{figure*}[t] +\ifdim\linewidth<\textwidth + \errmessage{Double-column figure lost its full text width} +\fi +\centering +Double-column figure. +\end{figure*} +``` + +See @tbl-switched. + +| Name | Value | +|------|------:| +| Switched | 1 | + +: Switched caption {#tbl-switched .column-page tbl-cap-location="bottom"} + +```{=latex} +\onecolumn +``` + +| Name | Value | +|------|------:| +| Restored | 2 | + +: Restored caption {#tbl-restored .column-page} diff --git a/tests/smoke/crossref/tables-float.test.ts b/tests/smoke/crossref/tables-float.test.ts index f6bdf01e81c..6c53822cac9 100644 --- a/tests/smoke/crossref/tables-float.test.ts +++ b/tests/smoke/crossref/tables-float.test.ts @@ -27,9 +27,10 @@ renderVerifyLatexOutput(docs("crossrefs/tables-float.qmd"), [ renderVerifyLatexOutput(docs("crossrefs/tables-twocolumn-list.qmd"), [ /\\begin\{table\}[^]*?\\caption\{List caption\}\\label\{tbl-list\}/, - /\\begin\{minipage\}[^]*?\\begin\{longtable\}/, + /\\begin\{minipage\}[^]*?\\begin\{tabular\}/, ], [ - /\\begin\{minipage\}[^]*?\\begin\{table\}/, + /\\begin\{longtable\}/, + /\\begin\{minipage\}(?:(?!\\end\{minipage\})[^])*\\begin\{table\}/, ]); testRender(docs("crossrefs/tables-twocolumn.qmd"), "pdf", true, [ @@ -40,3 +41,45 @@ testRender(docs("crossrefs/tables-twocolumn.qmd"), "pdf", true, [ /See Table 1, Table 2, and Table 3 \(especially Table 3a\)/, ], [/\?\?/]), ]); + +renderVerifyLatexOutput(docs("crossrefs/tables-float-layouts.qmd"), [ + /\\begin\{table\*\}\[t\][^]*?\\end\{tabular\}[^]*?\\caption\[Short wide caption\]\{\\label\{tbl-wide\}Wide caption\}/, + /\\multicolumn\{2\}/, + /\\multirow\{2\}/, + /\\real\{0\.3000\}/, + /\\real\{0\.7000\}/, + /Doe[^]*?2020/, +], [ + /\\begin\{longtable\}/, + /85b77c8a-261c-4f58-9b04-f21c67e0a758/, + /\\begin\{minipage\}(?:(?!\\end\{minipage\})[^])*\\begin\{table\}/, +]); + +testRender(docs("crossrefs/tables-float-layouts.qmd"), "pdf", true, [ + ensurePdfRegexMatches(docs("crossrefs/tables-float-layouts.pdf"), [ + /Table 1: Wide caption/, + /Table 2: Bottom caption/, + /Table 3: After caption/, + /See Table 1, Table 2, and Table 3/, + /Doe[^]*?2020/, + /Table note text\./, + ], [/\?\?/]), +]); + +testRender(docs("crossrefs/tables-float-margin.qmd"), "pdf", true, [ + ensurePdfRegexMatches(docs("crossrefs/tables-float-margin.pdf"), [ + /Table 1: Margin\s+caption/, + /Table 2: Wide one-column caption/, + /See Table 1 and Table 2/, + /Bare margin/, + ], [/\?\?/]), +]); + +testRender(docs("crossrefs/tables-float-switch.qmd"), "pdf", true, [ + ensurePdfRegexMatches(docs("crossrefs/tables-float-switch.pdf"), [ + /Table 1: Switched caption/, + /Table 2: Restored caption/, + /See Table 1/, + /Double-column figure/, + ], [/\?\?/]), +]); From ec21521e679d224f18cc1548301973a0d7b4c957 Mon Sep 17 00:00:00 2001 From: enoch Date: Tue, 20 Oct 2026 13:13:43 +0800 Subject: [PATCH 3/5] docs: explain sidenotes float environment dispatch --- src/resources/filters/layout/meta.lua | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/resources/filters/layout/meta.lua b/src/resources/filters/layout/meta.lua index 97a04591fe2..3eb1d1b6146 100644 --- a/src/resources/filters/layout/meta.lua +++ b/src/resources/filters/layout/meta.lua @@ -111,16 +111,29 @@ function layout_meta_inject_latex_packages() -- double-column floats. Choose at use time, since journal styles may -- switch columns in the document rather than via classoption. inject([[ +% Install the dispatchers only when Quarto loads sidenotes itself. If the +% document already loaded it, leave that existing package setup untouched. \@ifpackageloaded{sidenotes}{}{% + % Save the document class's native double-column float environments before + % sidenotes replaces them. Save both the opening and closing commands. + % csname addresses environment commands whose names contain a literal '*'; + % expandafter lets \let copy the resulting command's current definition. \expandafter\let\expandafter\quarto@native@table\csname table*\endcsname \expandafter\let\expandafter\quarto@native@endtable\csname endtable*\endcsname \expandafter\let\expandafter\quarto@native@figure\csname figure*\endcsname \expandafter\let\expandafter\quarto@native@endfigure\csname endfigure*\endcsname \usepackage{sidenotes} + % Keep sidenotes' versions too: in single-column documents they extend a + % regular float into the margin, which is the intended column-page layout. \expandafter\let\expandafter\quarto@margin@table\csname table*\endcsname \expandafter\let\expandafter\quarto@margin@endtable\csname endtable*\endcsname \expandafter\let\expandafter\quarto@margin@figure\csname figure*\endcsname \expandafter\let\expandafter\quarto@margin@endfigure\csname endfigure*\endcsname + % Select the implementation when each environment is used, not when the + % preamble is read. A journal style or the document body may call + % \twocolumn or \onecolumn after sidenotes has already been loaded. + % Expand past \else or \fi before calling an opening command so its + % optional placement argument (such as [t]) is still the next input token. \expandafter\def\csname table*\endcsname{% \if@twocolumn\expandafter\quarto@native@table\else\expandafter\quarto@margin@table\fi} \expandafter\def\csname endtable*\endcsname{% From 7217a684f67fc176e01155820e8024f0f3cbc163 Mon Sep 17 00:00:00 2001 From: enoch Date: Mon, 26 Oct 2026 21:43:47 +0800 Subject: [PATCH 4/5] fix: address table regressions and preserve Markdown code languages --- .../filters/customnodes/floatreftarget.lua | 7 +- src/resources/filters/main.lua | 4 + src/resources/filters/quarto-post/code.lua | 31 +++++++- src/resources/filters/quarto-post/latex.lua | 7 +- tests/docs/crossrefs/tables-float-margin.qmd | 10 +++ .../2026/10/26/code-language-markers.qmd | 74 +++++++++++++++++++ tests/smoke/crossref/tables-float.test.ts | 1 + 7 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 tests/docs/smoke-all/2026/10/26/code-language-markers.qmd diff --git a/src/resources/filters/customnodes/floatreftarget.lua b/src/resources/filters/customnodes/floatreftarget.lua index e5f989bdff1..eda7e88bc8b 100644 --- a/src/resources/filters/customnodes/floatreftarget.lua +++ b/src/resources/filters/customnodes/floatreftarget.lua @@ -344,9 +344,10 @@ end, function(float) return float.content end - -- Native tables in full-width/margin floats, or with bottom captions, need - -- Quarto's environment and caption rather than Pandoc's default longtable. - if not float.parent_id and float.content.t == "Table" and float_type == "tbl" then + -- Only floating tables need Quarto's environment and bottom caption here. + -- Ordinary single-column tables must retain longtable and its caption foot. + if not float.parent_id and float.content.t == "Table" and float_type == "tbl" and + (figEnv ~= "table" or float.classes:includes("float")) then figPos = float.attributes["latex-placement"] or figPos if figEnv == "marginfigure" then figEnv = "margintable" diff --git a/src/resources/filters/main.lua b/src/resources/filters/main.lua index 3f454b00793..0bbad8f431f 100644 --- a/src/resources/filters/main.lua +++ b/src/resources/filters/main.lua @@ -567,6 +567,10 @@ local quarto_post_filters = { filter = render_typst_brand_yaml(), traverser = 'jog', }, + { name = "post-render-markdown-code-languages", + filter = preserve_markdown_code_languages(), + traverser = 'jog', + }, { name = "post-render-gfm-fixups", filter = render_gfm_fixups(), traverser = 'jog', diff --git a/src/resources/filters/quarto-post/code.lua b/src/resources/filters/quarto-post/code.lua index 7d9df6ccc4e..38c2d97b033 100644 --- a/src/resources/filters/quarto-post/code.lua +++ b/src/resources/filters/quarto-post/code.lua @@ -35,4 +35,33 @@ function removeCodeOptions() end end } -end \ No newline at end of file +end + +function preserve_markdown_code_languages() + return { + CodeBlock = function(code) + if not _quarto.format.isMarkdownOutput() or + tcontains(PANDOC_WRITER_OPTIONS.extensions, "fenced_code_attributes") then + return nil + end + -- Since Pandoc 3.10.1, bare classes are only treated as languages if + -- Skylighting knows them. Explicit language-* classes still preserve + -- arbitrary names, including those interpreted by downstream renderers. + -- Match the previous writer's first non-sourceCode class convention, + -- while respecting an already-explicit language anywhere in the list. + local language + for _, class in ipairs(code.classes) do + if class:match("^language%-") then + return nil + end + if language == nil and class ~= "sourceCode" then + language = class + end + end + if language then + code.classes:insert("language-" .. language) + return code + end + end + } +end diff --git a/src/resources/filters/quarto-post/latex.lua b/src/resources/filters/quarto-post/latex.lua index 59fd9f7504c..a89263afeb0 100644 --- a/src/resources/filters/quarto-post/latex.lua +++ b/src/resources/filters/quarto-post/latex.lua @@ -192,6 +192,10 @@ function render_latex() end function handle_table_columns(table) + -- Code-cell layout may already have supplied the enclosing environment. + if table.classes:includes("render-as-tabular") then + return latexTabular(table) + end local full_width = table.classes:find_if(isStarEnv) local in_margin = table.classes:find_if(isMarginEnv) if full_width or in_margin then @@ -202,9 +206,6 @@ function render_latex() content = latexTabular(table) })) end - if table.classes:includes("render-as-tabular") then - return latexTabular(table) - end end diff --git a/tests/docs/crossrefs/tables-float-margin.qmd b/tests/docs/crossrefs/tables-float-margin.qmd index a8443aeb5df..e0f88ffa69f 100644 --- a/tests/docs/crossrefs/tables-float-margin.qmd +++ b/tests/docs/crossrefs/tables-float-margin.qmd @@ -23,3 +23,13 @@ See @tbl-margin and @tbl-wide-onecolumn. | Bare margin | 3 | ::: + +::: {.cell .column-margin} +::: {.cell-output-display} + +| Name | Value | +|------|------:| +| Cell margin | 4 | + +::: +::: diff --git a/tests/docs/smoke-all/2026/10/26/code-language-markers.qmd b/tests/docs/smoke-all/2026/10/26/code-language-markers.qmd new file mode 100644 index 00000000000..b27415d7d30 --- /dev/null +++ b/tests/docs/smoke-all/2026/10/26/code-language-markers.qmd @@ -0,0 +1,74 @@ +--- +format: + gfm: default + md: default + markdown: default +_quarto: + tests: + gfm: + ensureFileRegexMatches: + - ['``` mermaid', '``` geojson', '``` topojson', '``` stl', '``` plantuml', '``` math', '``` quarto-test-language', '``` text', '``` python', '``` explicit-language'] + - ['language-language-'] + md: + ensureFileRegexMatches: + - ['``` mermaid', '``` geojson', '``` topojson', '``` stl', '``` plantuml', '``` math', '``` quarto-test-language', '``` text', '``` python', '``` explicit-language'] + - ['language-language-'] + markdown: + ensureFileRegexMatches: + - ['\.quarto-test-language', '\.extra-class'] + - ['language-quarto-test-language'] +--- + +```mermaid +graph LR + A --> B +``` + +```geojson +{"type":"Point","coordinates":[0,0]} +``` + +```topojson +{"type":"Topology","objects":{}} +``` + +```stl +solid example +endsolid example +``` + +```plantuml +Alice -> Bob: hello +``` + +```math +x^2 +``` + +```{.quarto-test-language .extra-class} +arbitrary language +``` + +```text +plain output +``` + +```python +print("hello") +``` + +```{.sourceCode .language-explicit-language} +explicit language +``` + +> ```quarto-test-language +> quoted code +> ``` + +- Nested code: + + ````quarto-test-language + ``` + nested fence + ``` + ```` diff --git a/tests/smoke/crossref/tables-float.test.ts b/tests/smoke/crossref/tables-float.test.ts index 6c53822cac9..f2d634690fe 100644 --- a/tests/smoke/crossref/tables-float.test.ts +++ b/tests/smoke/crossref/tables-float.test.ts @@ -72,6 +72,7 @@ testRender(docs("crossrefs/tables-float-margin.qmd"), "pdf", true, [ /Table 2: Wide one-column caption/, /See Table 1 and Table 2/, /Bare margin/, + /Cell margin/, ], [/\?\?/]), ]); From ad5845d9fedb121bd9628405bea0586af3755ad9 Mon Sep 17 00:00:00 2001 From: enoch Date: Tue, 27 Oct 2026 09:21:41 +0800 Subject: [PATCH 5/5] test: accept compact Typst definitions from Pandoc 3.10.1 --- .../smoke-all/markdown/definition-list/definition-list-div.qmd | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/docs/smoke-all/markdown/definition-list/definition-list-div.qmd b/tests/docs/smoke-all/markdown/definition-list/definition-list-div.qmd index a750a0a6c89..1b86d562ff2 100644 --- a/tests/docs/smoke-all/markdown/definition-list/definition-list-div.qmd +++ b/tests/docs/smoke-all/markdown/definition-list/definition-list-div.qmd @@ -29,7 +29,8 @@ _quarto: typst: ensureTypstFileRegexMatches: - - '/ Loose term: #block\[' - - '/ Inner term: #block\[' + # Pandoc 3.10.1 emits tight plain definitions without a block wrapper. + - '/ Inner term:\s*(?:#block\[\s*)?Inner definition\.' - '/ Table term: #block\[\s*#table\(' - [] ---