From 559ea3f9c2aa59987efcc540a25dcbfc3b6e6bdd Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 30 Jul 2026 16:34:31 +0000 Subject: [PATCH 1/3] Drop Pandoc's LTcaptype brace group around cross-ref longtables Pandoc 3.8.1+ wraps a captionless table in `{\def\LTcaptype{none} ... }` so it doesn't step the table counter. Quarto's longtable fixup adds its own `\caption`, so it strips the definition but kept the braces, on the assumption they were harmless. They aren't. Pandoc's LaTeX template does `\makesavenoteenv{longtable}`, and footnotehyper implements that with `env/longtable/before`/`after` hooks that `\begingroup`/`\endgroup` around the environment. endfloat's `\DeclareDelayedFloatFlavor*{longtable}{table}` reads the environment into the .ttt file and terminates it with `\end{efloat@float}`, so the `after` hook never fires and the `\savenotes` group stays open. Quarto's trailing `}` is the first token to meet it, and LaTeX fails with "Extra }, or forgotten \endgroup". Drop the braces along with the definition, but only when they are provably Pandoc's own wrapper: the preamble is the opening brace and the definition and nothing else (anything else in the group is scoped by it and would leak), the postamble is the closing brace and nothing else, and the raw block holds a single longtable (the pattern spans first \begin to last \end, so with two of them the braces belong to different groups and removing both unbalances the output). Anything else falls back to dropping the definition alone, exactly as before. Rendered output is otherwise unchanged: table numbering, cross-references and caption placement all come from Quarto's own `\caption`. Regression introduced in v1.9.17, which bumped Pandoc 3.6.3 -> 3.8.3. Fixes #14741 --- news/changelog-1.11.md | 4 + .../filters/customnodes/floatreftarget.lua | 37 ++++++++- .../smoke-all/2026/07/30/14741-raw-latex.qmd | 82 +++++++++++++++++++ tests/docs/smoke-all/2026/07/30/14741.qmd | 61 ++++++++++++++ 4 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 tests/docs/smoke-all/2026/07/30/14741-raw-latex.qmd create mode 100644 tests/docs/smoke-all/2026/07/30/14741.qmd diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 35f6ee3a28..c36e061e55 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -1,5 +1,9 @@ All changes included in 1.11: +## Regression fixes + +- ([#14741](https://github.com/quarto-dev/quarto-cli/issues/14741)): Don't wrap the `longtable` environment of a cross-referenceable table in a `{ ... }` group. Pandoc emits that group to scope its `\def\LTcaptype{none}`, which Quarto removes when adding its own `\caption`; keeping the now-pointless group broke packages that move the environment out of the text flow, such as `endfloat` with `\DeclareDelayedFloatFlavor*{longtable}{table}`. + ## Engines ### `knitr` diff --git a/src/resources/filters/customnodes/floatreftarget.lua b/src/resources/filters/customnodes/floatreftarget.lua index 5e06f31f1e..c65f927ece 100644 --- a/src/resources/filters/customnodes/floatreftarget.lua +++ b/src/resources/filters/customnodes/floatreftarget.lua @@ -457,9 +457,40 @@ end, function(float) "triggered this error.") return {} end - -- Strip Pandoc 3.8+ LTcaptype definition since we're adding our own caption - -- Keep the { } wrapper (harmless) to avoid orphan braces - longtable_preamble = longtable_preamble:gsub("\\def\\LTcaptype{none}[^\n]*\n?", "") + -- Pandoc 3.8.1+ wraps captionless tables in a brace group whose only + -- purpose is to scope `\def\LTcaptype{none}` (so that the table counter + -- isn't incremented). We add our own \caption here, so the definition has + -- to go - and once it's gone, the group has no purpose left. It isn't inert: + -- it breaks packages that move the longtable environment out of the text + -- flow, notably endfloat's \DeclareDelayedFloatFlavor*{longtable}{table} + -- (see https://github.com/quarto-dev/quarto-cli/issues/14741), so we drop + -- the braces along with the definition. + -- + -- We only do that when the braces are provably Pandoc's own wrapper and + -- nothing else: + -- * the preamble is the opening brace and the definition, and nothing + -- more - anything else in the group (say a `\setlength`) is scoped by + -- it and would leak if we removed the brace; + -- * the postamble is the closing brace, and nothing more; + -- * this raw block holds a single longtable - the pattern above spans + -- from the first \begin{longtable} to the last \end{longtable}, so + -- with two of them the two braces belong to different groups and + -- removing both leaves the output unbalanced. + -- Otherwise we fall back to dropping the definition alone, which is what + -- Quarto has always done. + local preamble_without_group, opened = longtable_preamble:gsub( + "^(%s*){%s*\\def\\LTcaptype{none}[^\n]*\n(%s*)$", "%1%2") + local postamble_without_group, closed = longtable_postamble:gsub( + "^(%s*)}(%s*)$", "%1%2") + local single_longtable = + longtable_content:find("\\begin{longtable}", 1, true) == nil + if opened > 0 and closed > 0 and single_longtable then + longtable_preamble = preamble_without_group + longtable_postamble = postamble_without_group + else + longtable_preamble = + longtable_preamble:gsub("\\def\\LTcaptype{none}[^\n]*\n?", "") + end -- split the content into params and actual content -- params are everything in the first line of longtable_content -- actual content is everything else diff --git a/tests/docs/smoke-all/2026/07/30/14741-raw-latex.qmd b/tests/docs/smoke-all/2026/07/30/14741-raw-latex.qmd new file mode 100644 index 0000000000..1a2047142c --- /dev/null +++ b/tests/docs/smoke-all/2026/07/30/14741-raw-latex.qmd @@ -0,0 +1,82 @@ +--- +format: pdf +keep-tex: true +_quarto: + tests: + pdf: + # an unbalanced brace makes LaTeX fail with "Too many }'s" + noErrors: default + ensureLatexFileRegexMatches: + - + # the author's own \LTcaptype definition, and the group scoping it, survive + - '\{\\def\\LTcaptype\{figure\}' + # a group holding more than Pandoc's definition survives whole + - '\{\\setlength\{\\arrayrulewidth\}\{2\.5pt\}' + # each table still gets its caption + - '\\caption\{\\label\{tbl-two-groups\}' + - '\\caption\{\\label\{tbl-user-def\}' + - '\\caption\{\\label\{tbl-extra-in-group\}' + - [] +--- + +Companion to `14741.qmd`. Removing the `{ ... }` group Pandoc puts around a +captionless longtable is only safe when those braces are provably Pandoc's own +wrapper. Raw LaTeX can carry the same shape without the same meaning, and the +three cases below are the ones where dropping the braces would corrupt the +output. Each must come through byte for byte as the author wrote it. + +## Two groups in one raw block + +The pattern that splits the raw block spans from the first `\begin{longtable}` +to the last `\end{longtable}`, so here the opening brace belongs to the first +group and the closing brace to the second. Removing both leaves one orphan of +each, and `pdflatex` stops with `Too many }'s`. + +::: {#tbl-two-groups .cell} +```{=latex} +{\def\LTcaptype{none} % do not increment counter +\begin{longtable}[]{@{}l@{}} +a \\ +\end{longtable} +} +{\def\LTcaptype{none} % do not increment counter +\begin{longtable}[]{@{}l@{}} +b \\ +\end{longtable} +} +``` +Two groups in one raw block +::: + +## The author's own `\LTcaptype` + +`\def\LTcaptype{figure}` is the documented way to make a longtable caption use +the figure counter. It is not Pandoc's marker and must not be touched. + +::: {#tbl-user-def .cell} +```{=latex} +{\def\LTcaptype{figure} +\begin{longtable}[]{@{}lr@{}} +a & b \\ +\end{longtable} +} +``` +Author-defined LTcaptype +::: + +## A group that scopes more than the definition + +Anything else inside the group is scoped by it. Dropping the brace would let +these settings leak into the rest of the document. + +::: {#tbl-extra-in-group .cell} +```{=latex} +{\def\LTcaptype{none} % do not increment counter +\setlength{\arrayrulewidth}{2.5pt} +\begin{longtable}[]{@{}l@{}} +a \\ +\end{longtable} +} +``` +Group with a setlength in it +::: diff --git a/tests/docs/smoke-all/2026/07/30/14741.qmd b/tests/docs/smoke-all/2026/07/30/14741.qmd new file mode 100644 index 0000000000..450066cfea --- /dev/null +++ b/tests/docs/smoke-all/2026/07/30/14741.qmd @@ -0,0 +1,61 @@ +--- +format: latex +include-in-header: + text: | + \usepackage[nomarkers,nolists]{endfloat} + \DeclareDelayedFloatFlavor*{longtable}{table} +_quarto: + tests: + latex: + ensureFileRegexMatches: + - + - '\\begin\{longtable\}' + - '\\caption\{\\label\{tbl-kable\}' + - '\\end\{longtable\}' + - + # no `{ ... }` group around the longtable environment + - '\{\s*\\begin\{longtable\}' + - '\\end\{longtable\}\s*\}' + # and Pandoc's definition, which the group scoped, is gone with it + - '\\def\\LTcaptype' +--- + +Pandoc 3.8.1+ wraps a captionless table in `{\def\LTcaptype{none} ... }` so it +does not step the table counter. Quarto lifts the caption off a +cross-referenceable table before Pandoc writes it, so every such table takes +that path; Quarto then supplies its own `\caption` and drops the definition, +which leaves the group scoping nothing. + +That leftover group is not inert. `endfloat`'s +`\DeclareDelayedFloatFlavor*{longtable}{table}` reads the environment into the +`.ttt` file and terminates it with `\end{efloat@float}`, so the +`env/longtable/after` hook installed by `footnotehyper`'s +`\makesavenoteenv{longtable}` never fires and its `\savenotes` `\begingroup` +stays open. The trailing `}` is then the first token to meet that open group, +and LaTeX fails with `Extra }, or forgotten \endgroup`. + +The assertions above check the generated `.tex`, not a compile: reproducing the +failure needs `footnotehyper`, and TinyTeX ships `footnote.sty` instead — the +template picks between them with `\IfFileExists`, which never errors, so nothing +installs `footnotehyper` on CI and a compiling test would pass either way. The +`endfloat` preamble above is therefore inert here; it is kept so this document +is one metadata switch away from being the real reproduction. + +The Div below is the shape knitr/Jupyter produce for a markdown table emitted +by a code cell. `knitr::kable()` defaults to the pipe format under Quarto, so +this is what the issue's `kable(longtable = TRUE)` reprex produces; see +`14741-knitr.qmd` for the engine version. + +::: {#tbl-kable .cell} +::: {.cell-output-display} + +Table: A table generated from an R data frame using knitr::kable(). + +|Treatment | Sample size| Mean response| +|:-----------|-----------:|-------------:| +|Control | 20| 12.3| +|Treatment A | 20| 15.8| +|Treatment B | 20| 14.6| + +::: +::: From 9f47a37888edec8aed2f7567c7bfc8fa08b1103a Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 30 Jul 2026 16:34:31 +0000 Subject: [PATCH 2/3] Add knitr variant of the #14741 longtable-group test The issue's reprex uses `knitr::kable(longtable = TRUE)`, which under Quarto defaults to the pipe format - so the cell emits a markdown table and Pandoc's writer produces the longtable. That is the shape 14741.qmd covers; this adds the engine version that goes through knitr for real, plus a second cell with `format = "latex"` for the raw-LaTeX path. --- .../docs/smoke-all/2026/07/30/14741-knitr.qmd | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tests/docs/smoke-all/2026/07/30/14741-knitr.qmd diff --git a/tests/docs/smoke-all/2026/07/30/14741-knitr.qmd b/tests/docs/smoke-all/2026/07/30/14741-knitr.qmd new file mode 100644 index 0000000000..4a837c71bb --- /dev/null +++ b/tests/docs/smoke-all/2026/07/30/14741-knitr.qmd @@ -0,0 +1,57 @@ +--- +format: latex +_quarto: + tests: + latex: + ensureFileRegexMatches: + - + - '\\caption\{\\label\{tbl-kable\}' + - '\\caption\{\\label\{tbl-kable-latex\}' + - + # no `{ ... }` group around either longtable environment + - '\{\s*\\begin\{longtable\}' + - '\\end\{longtable\}\s*\}' + - '\\def\\LTcaptype' +--- + +The knitr version of `14741.qmd`, mirroring the reprex in the issue. See that +document for why the `{ ... }` group Pandoc puts around a captionless table +must not survive into the output. + +Under Quarto, `knitr::kable()` defaults to the pipe format, so the first cell +emits a markdown table — `longtable = TRUE` is a no-op there — and Pandoc's +writer produces the longtable. That is the path which gained the stray group. + +The second cell passes `format = "latex"`, so knitr emits the longtable itself +as raw LaTeX and Pandoc's table writer never runs. That table carries no group +at all — knitr's output has an empty preamble and postamble — so it only checks +that the fixup introduces nothing there. `14741-raw-latex.qmd` is the document +that exercises raw LaTeX which does carry braces. + +```{r} +#| label: tbl-kable +results <- data.frame( + treatment = c("Control", "Treatment A", "Treatment B"), + sample_size = c(20, 20, 20), + mean_response = c(12.3, 15.8, 14.6) +) +knitr::kable( + results, + longtable = TRUE, + booktabs = FALSE, + caption = "A table generated from an R data frame using knitr::kable().", + col.names = c("Treatment", "Sample size", "Mean response") +) +``` + +```{r} +#| label: tbl-kable-latex +knitr::kable( + results, + format = "latex", + longtable = TRUE, + booktabs = FALSE, + caption = "The same table, emitted as raw LaTeX by knitr.", + col.names = c("Treatment", "Sample size", "Mean response") +) +``` From 27f66f1712be17cffe159037580f17a70594faed Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 31 Jul 2026 16:01:22 +0200 Subject: [PATCH 3/3] Trim the longtable-group comment The full endfloat/footnotehyper chain lives in #14741 and the per-guard rationale in the 14741-raw-latex test doc; the inline comment only needs enough to read the code. --- .../filters/customnodes/floatreftarget.lua | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/src/resources/filters/customnodes/floatreftarget.lua b/src/resources/filters/customnodes/floatreftarget.lua index c65f927ece..eaa0494e66 100644 --- a/src/resources/filters/customnodes/floatreftarget.lua +++ b/src/resources/filters/customnodes/floatreftarget.lua @@ -457,27 +457,14 @@ end, function(float) "triggered this error.") return {} end - -- Pandoc 3.8.1+ wraps captionless tables in a brace group whose only - -- purpose is to scope `\def\LTcaptype{none}` (so that the table counter - -- isn't incremented). We add our own \caption here, so the definition has - -- to go - and once it's gone, the group has no purpose left. It isn't inert: - -- it breaks packages that move the longtable environment out of the text - -- flow, notably endfloat's \DeclareDelayedFloatFlavor*{longtable}{table} - -- (see https://github.com/quarto-dev/quarto-cli/issues/14741), so we drop - -- the braces along with the definition. - -- - -- We only do that when the braces are provably Pandoc's own wrapper and - -- nothing else: - -- * the preamble is the opening brace and the definition, and nothing - -- more - anything else in the group (say a `\setlength`) is scoped by - -- it and would leak if we removed the brace; - -- * the postamble is the closing brace, and nothing more; - -- * this raw block holds a single longtable - the pattern above spans - -- from the first \begin{longtable} to the last \end{longtable}, so - -- with two of them the two braces belong to different groups and - -- removing both leaves the output unbalanced. - -- Otherwise we fall back to dropping the definition alone, which is what - -- Quarto has always done. + -- Pandoc 3.8.1+ wraps a captionless table in a brace group that only + -- scopes `\def\LTcaptype{none}`. We supply our own \caption and drop that + -- definition, so the group is now pointless - and not inert: it breaks + -- packages that move the environment out of the text flow (endfloat's + -- \DeclareDelayedFloatFlavor*{longtable}{table}, #14741). Drop the braces + -- with the definition, but only when provably Pandoc's own wrapper: + -- preamble is just the brace + def, postamble is just the brace, and the + -- block holds a single longtable. Otherwise strip the definition alone. local preamble_without_group, opened = longtable_preamble:gsub( "^(%s*){%s*\\def\\LTcaptype{none}[^\n]*\n(%s*)$", "%1%2") local postamble_without_group, closed = longtable_postamble:gsub(