From cc3008302482029d87b6edfe0d0797e99f594de4 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 10 Jun 2026 16:21:33 +0200 Subject: [PATCH 1/7] fix: guard empty div content in latexdiv.lua MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `divEl.content[1].t` crashes when a div with `latex` attribute has no content blocks. Add `#divEl.content > 0` guard so empty divs fall through to the RawBlock branch, producing valid \begin{env}\end{env}. quarto.utils.match() not used here — the first-and-last element check with content mutation doesn't map to its path-traversal pattern. --- .../filters/quarto-post/latexdiv.lua | 2 +- .../docs/smoke-all/latex/empty-latex-div.qmd | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 tests/docs/smoke-all/latex/empty-latex-div.qmd diff --git a/src/resources/filters/quarto-post/latexdiv.lua b/src/resources/filters/quarto-post/latexdiv.lua index 4d0a0d0c384..d5068024c72 100644 --- a/src/resources/filters/quarto-post/latexdiv.lua +++ b/src/resources/filters/quarto-post/latexdiv.lua @@ -34,7 +34,7 @@ function latexDiv() -- if the first and last div blocks are paragraphs then we can -- bring the environment begin/end closer to the content - if divEl.content[1].t == "Para" and divEl.content[#divEl.content].t == "Para" then + if #divEl.content > 0 and divEl.content[1].t == "Para" and divEl.content[#divEl.content].t == "Para" then table.insert(divEl.content[1].content, 1, pandoc.RawInline('tex', beginEnv .. "\n")) table.insert(divEl.content[#divEl.content].content, pandoc.RawInline('tex', "\n" .. endEnv)) else diff --git a/tests/docs/smoke-all/latex/empty-latex-div.qmd b/tests/docs/smoke-all/latex/empty-latex-div.qmd new file mode 100644 index 00000000000..961ec3a0f07 --- /dev/null +++ b/tests/docs/smoke-all/latex/empty-latex-div.qmd @@ -0,0 +1,24 @@ +--- +title: Empty LaTeX div +format: pdf +keep-tex: true +_quarto: + tests: + pdf: + noErrors: default + ensureLatexFileRegexMatches: + - + - '\\begin\{center\}' + - '\\end\{center\}' +--- + +## Non-empty LaTeX div + +::: {.center latex="true"} +Some centered content. +::: + +## Empty LaTeX div + +::: {.center latex="true"} +::: From 4598ddfdf8e391ca95af5f4b8d59f523d76e95ad Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 10 Jun 2026 16:35:30 +0200 Subject: [PATCH 2/7] fix: logic bug in parsefiguredivs.lua Figure bail-out condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#fig.content ~= 1 and fig.content[1].t ~= "Plain"` uses `and` but the intent is "bail unless content is exactly one Plain block." With `and`, empty content crashes (indexes nil) and single non-Plain doesn't bail. Change to `or` — short-circuits on empty, bails on non-Plain. Test uses a pre-ast Lua filter to strip Figure content, confirming the crash triggers at the normalize stage. --- .../filters/quarto-pre/parsefiguredivs.lua | 2 +- .../float/empty-figure-content-filter.lua | 7 ++++++ .../crossrefs/float/empty-figure-content.qmd | 25 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 tests/docs/smoke-all/crossrefs/float/empty-figure-content-filter.lua create mode 100644 tests/docs/smoke-all/crossrefs/float/empty-figure-content.qmd diff --git a/src/resources/filters/quarto-pre/parsefiguredivs.lua b/src/resources/filters/quarto-pre/parsefiguredivs.lua index 6215af8f2e0..34f74354384 100644 --- a/src/resources/filters/quarto-pre/parsefiguredivs.lua +++ b/src/resources/filters/quarto-pre/parsefiguredivs.lua @@ -486,7 +486,7 @@ function parse_floatreftargets() if category == nil then return nil end - if #fig.content ~= 1 and fig.content[1].t ~= "Plain" then + if #fig.content ~= 1 or fig.content[1].t ~= "Plain" then -- we don't know how to parse this pandoc 3 figure -- just return as is return nil diff --git a/tests/docs/smoke-all/crossrefs/float/empty-figure-content-filter.lua b/tests/docs/smoke-all/crossrefs/float/empty-figure-content-filter.lua new file mode 100644 index 00000000000..ab15f03d174 --- /dev/null +++ b/tests/docs/smoke-all/crossrefs/float/empty-figure-content-filter.lua @@ -0,0 +1,7 @@ +-- Test helper: empties Figure content to trigger unguarded .content[1].t access +function Figure(fig) + if fig.identifier == "fig-emptied" then + fig.content = pandoc.Blocks({}) + return fig + end +end diff --git a/tests/docs/smoke-all/crossrefs/float/empty-figure-content.qmd b/tests/docs/smoke-all/crossrefs/float/empty-figure-content.qmd new file mode 100644 index 00000000000..0fe211a47bc --- /dev/null +++ b/tests/docs/smoke-all/crossrefs/float/empty-figure-content.qmd @@ -0,0 +1,25 @@ +--- +title: Empty Figure content guard +format: pdf +keep-tex: true +filters: + - at: pre-ast + path: empty-figure-content-filter.lua +_quarto: + tests: + pdf: + noErrors: default + ensureLatexFileRegexMatches: + - + - '\\begin\{figure\}' +--- + +## Normal figure + +![A caption](img/surus.jpg){#fig-normal} + +See @fig-normal. + +## Figure with content stripped by filter + +![Emptied caption](img/surus.jpg){#fig-emptied} From 06dbb21cde1c9b7d42f72a03951ef06b96c6dd68 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 10 Jun 2026 16:39:23 +0200 Subject: [PATCH 3/7] fix: migrate pandoc3_figure.lua to quarto.utils.match() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTML path: replace manual `figure.content[1].t == "Plain"` then `plain.content[1].t == "Link"` chain with single `quarto.utils.match("[1]/Plain/[1]/Link")(figure)` call. match() returns false on nil/empty content — no crash possible. Typst path: add `#figure.content == 0` guard before accessing figure.content[1] (match() doesn't fit here — the value is passed to make_typst_figure, not used as a type check). Test uses pre-ast Lua filter to empty non-crossref Figure content. --- src/resources/filters/layout/pandoc3_figure.lua | 11 ++++------- .../float/empty-pandoc3-figure-filter.lua | 8 ++++++++ .../crossrefs/float/empty-pandoc3-figure.qmd | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 tests/docs/smoke-all/crossrefs/float/empty-pandoc3-figure-filter.lua create mode 100644 tests/docs/smoke-all/crossrefs/float/empty-pandoc3-figure.qmd diff --git a/src/resources/filters/layout/pandoc3_figure.lua b/src/resources/filters/layout/pandoc3_figure.lua index 42b66e54f28..508509ee975 100644 --- a/src/resources/filters/layout/pandoc3_figure.lua +++ b/src/resources/filters/layout/pandoc3_figure.lua @@ -11,13 +11,7 @@ function render_pandoc3_figure() local function html_handle_linked_image(figure) local div = pandoc.Div({}) div.identifier = "fig-yesiamafigure" -- this is a bad hack to make discoverLinkedFigureDiv work - local link = nil - if figure.content[1].t == "Plain" then - local plain = figure.content[1] - if plain.content[1].t == "Link" then - link = plain.content[1] - end - end + local link = quarto.utils.match("[1]/Plain/[1]/Link")(figure) if link == nil then return nil end @@ -197,6 +191,9 @@ function render_pandoc3_figure() end end end + if #figure.content == 0 then + return nil + end return make_typst_figure({ content = figure.content[1], caption = figure.caption.long[1], diff --git a/tests/docs/smoke-all/crossrefs/float/empty-pandoc3-figure-filter.lua b/tests/docs/smoke-all/crossrefs/float/empty-pandoc3-figure-filter.lua new file mode 100644 index 00000000000..18155d7b19b --- /dev/null +++ b/tests/docs/smoke-all/crossrefs/float/empty-pandoc3-figure-filter.lua @@ -0,0 +1,8 @@ +-- Test helper: empties non-crossref Figure content to trigger +-- unguarded .content[1].t access in pandoc3_figure.lua +function Figure(fig) + if fig.identifier == "" then + fig.content = pandoc.Blocks({}) + return fig + end +end diff --git a/tests/docs/smoke-all/crossrefs/float/empty-pandoc3-figure.qmd b/tests/docs/smoke-all/crossrefs/float/empty-pandoc3-figure.qmd new file mode 100644 index 00000000000..5742c805c32 --- /dev/null +++ b/tests/docs/smoke-all/crossrefs/float/empty-pandoc3-figure.qmd @@ -0,0 +1,15 @@ +--- +title: Empty Pandoc 3 Figure content guard +format: html +filters: + - at: pre-ast + path: empty-pandoc3-figure-filter.lua +_quarto: + tests: + html: + noErrors: default +--- + +## Figure without cross-ref identifier + +![A plain figure caption](img/surus.jpg) From fa26f85089ac8a1aed792fbe305cee3ca426a9b3 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 10 Jun 2026 16:44:47 +0200 Subject: [PATCH 4/7] fix: migrate parsefiguredivs.lua:309 to quarto.utils.match() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual `content[1].content[1].t == "Image"` with `quarto.utils.match("[1]/Para/[1]/Image")(content)` which returns false on nil/empty content — no crash when Para has no inlines. Test uses pre-ast Lua filter to create a fig-div with an empty Para and external fig-cap attribute, confirming the crash at line 309. --- .../filters/quarto-pre/parsefiguredivs.lua | 8 +++---- .../float/empty-para-inlines-filter.lua | 10 +++++++++ .../crossrefs/float/empty-para-inlines.qmd | 21 +++++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 tests/docs/smoke-all/crossrefs/float/empty-para-inlines-filter.lua create mode 100644 tests/docs/smoke-all/crossrefs/float/empty-para-inlines.qmd diff --git a/src/resources/filters/quarto-pre/parsefiguredivs.lua b/src/resources/filters/quarto-pre/parsefiguredivs.lua index 34f74354384..466f0475f5c 100644 --- a/src/resources/filters/quarto-pre/parsefiguredivs.lua +++ b/src/resources/filters/quarto-pre/parsefiguredivs.lua @@ -305,11 +305,9 @@ function parse_floatreftargets() local identifier = div.identifier local attr = pandoc.Attr(identifier, div.classes, div.attributes) assert(content) - if (#content == 1 and content[1].t == "Para" and - content[1].content[1].t == "Image") then - -- if the div contains a single image, then we simply use the image as - -- the content - content = content[1].content[1] + local single_image = quarto.utils.match("[1]/Para/[1]/Image")(content) + if #content == 1 and single_image then + content = single_image -- don't merge classes because they often have CSS consequences -- but merge attributes because they're needed to correctly resolve diff --git a/tests/docs/smoke-all/crossrefs/float/empty-para-inlines-filter.lua b/tests/docs/smoke-all/crossrefs/float/empty-para-inlines-filter.lua new file mode 100644 index 00000000000..49886f1a437 --- /dev/null +++ b/tests/docs/smoke-all/crossrefs/float/empty-para-inlines-filter.lua @@ -0,0 +1,10 @@ +-- Test helper: creates a fig-div with a single empty Para (no inlines) +-- and a fig-cap attribute for caption, triggering unguarded +-- content[1].content[1].t access in parsefiguredivs.lua:309 +function Div(div) + if div.identifier == "fig-empty-para" then + div.content = pandoc.Blocks({pandoc.Para({})}) + div.attributes["fig-cap"] = "External caption" + return div + end +end diff --git a/tests/docs/smoke-all/crossrefs/float/empty-para-inlines.qmd b/tests/docs/smoke-all/crossrefs/float/empty-para-inlines.qmd new file mode 100644 index 00000000000..0dea796941f --- /dev/null +++ b/tests/docs/smoke-all/crossrefs/float/empty-para-inlines.qmd @@ -0,0 +1,21 @@ +--- +title: Empty Para inlines guard +format: html +filters: + - at: pre-ast + path: empty-para-inlines-filter.lua +_quarto: + tests: + html: + noErrors: default +--- + +## Figure div with content replaced by empty Para + +::: {#fig-empty-para} +![Normal image](img/surus.jpg) + +Normal caption +::: + +See @fig-empty-para. From fc6b9e884f3345b3b572a74e9cf112867d25919c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 10 Jun 2026 16:55:56 +0200 Subject: [PATCH 5/7] refactor: remove dead proof Div handler from crossref/theorems.lua MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Div handler in crossref_theorems() that rendered proof-type divs has been dead code since e644af3a4 (Dec 2023, "crossreferenceable remarks"). That commit introduced customnodes/proof.lua with a Proof custom node and renderer. The normalize stage (parseblockreftargets in astpipeline.lua) converts all proof-type divs to Proof custom nodes before crossref_theorems() runs — the Div handler never receives them. The theoremType branch already acknowledged this with internal_error(). Remove the entire Div handler; proof rendering is handled by proof.lua. New regression tests verify proof rendering (PDF and HTML) still works with content, named proofs, empty proofs, and remarks. --- src/resources/filters/crossref/theorems.lua | 92 ------------------- .../filters/quarto-post/latexdiv.lua | 2 +- .../theorem/proof-rendering-html.qmd | 35 +++++++ .../crossrefs/theorem/proof-rendering.qmd | 38 ++++++++ 4 files changed, 74 insertions(+), 93 deletions(-) create mode 100644 tests/docs/smoke-all/crossrefs/theorem/proof-rendering-html.qmd create mode 100644 tests/docs/smoke-all/crossrefs/theorem/proof-rendering.qmd diff --git a/src/resources/filters/crossref/theorems.lua b/src/resources/filters/crossref/theorems.lua index 68a7704caf9..de1fbb989b5 100644 --- a/src/resources/filters/crossref/theorems.lua +++ b/src/resources/filters/crossref/theorems.lua @@ -37,98 +37,6 @@ function crossref_theorems() proof.order = add_crossref(label, type, title) return proof end, - Div = function(el) - local type = refType(el.attr.identifier) - local theoremType = theorem_types[type] - if theoremType then - internal_error() - else - -- see if this is a proof, remark, or solution - local proof = proof_type(el) - if proof ~= nil then - - -- ensure requisite latex is injected - crossref.using_theorems = true - - if proof.env ~= "proof" then - el.attr.classes:insert("proof") - end - - -- capture then remove name - -- - -- we have string_to_quarto_ast_inlines but we don't need it here - -- because this filter happened after shortcode processing, and this - -- is a regular div we're processing - local name = markdownToInlines(el.attr.attributes["name"]) - if not name or #name == 0 then - name = resolveHeadingCaption(el) - end - el.attr.attributes["name"] = nil - - -- output - if _quarto.format.isLatexOutput() then - local preamble = pandoc.List() - preamble:insert(pandoc.RawInline("latex", "\\begin{" .. proof.env .. "}")) - if name ~= nil then - preamble:insert(pandoc.RawInline("latex", "[")) - tappend(preamble, name) - preamble:insert(pandoc.RawInline("latex", "]")) - end - preamble:insert(pandoc.RawInline("latex", "\n")) - -- https://github.com/quarto-dev/quarto-cli/issues/6077 - if el.content[1].t == "Para" then - preamble:extend(el.content[1].content) - el.content[1].content = preamble - else - if (el.content[1].t ~= "Para") then - -- required trick to get correct alignement when non Para content first - preamble:insert(pandoc.RawInline('latex', "\\leavevmode")) - end - el.content:insert(1, pandoc.Plain(preamble)) - end - local end_env = "\\end{" .. proof.env .. "}" - -- https://github.com/quarto-dev/quarto-cli/issues/6077 - if el.content[#el.content].t == "Para" then - el.content[#el.content].content:insert(pandoc.RawInline("latex", "\n" .. end_env)) - elseif el.content[#el.content].t == "RawBlock" and el.content[#el.content].format == "latex" then - -- this is required for no empty line between end_env and previous latex block - el.content[#el.content].text = el.content[#el.content].text .. "\n" .. end_env - else - el.content:insert(pandoc.RawBlock("latex", end_env)) - end - elseif _quarto.format.isJatsOutput() then - el = jatsTheorem(el, nil, name ) - else - local span = pandoc.Span( - { pandoc.Emph(pandoc.Str(envTitle(proof.env, proof.title)))}, - pandoc.Attr("", { "proof-title" }) - ) - if name ~= nil then - span.content:insert(pandoc.Str(" (")) - tappend(span.content, name) - span.content:insert(pandoc.Str(")")) - end - tappend(span.content, { pandoc.Str(". ")}) - - -- if the first block is a paragraph, then prepend the title span - if #el.content > 0 and - el.content[1].t == "Para" and - el.content[1].content ~= nil and - #el.content[1].content > 0 then - el.content[1].content:insert(1, span) - else - -- else insert a new paragraph - el.content:insert(1, pandoc.Para{span}) - end - end - - end - - end - - return el - - end } end diff --git a/src/resources/filters/quarto-post/latexdiv.lua b/src/resources/filters/quarto-post/latexdiv.lua index d5068024c72..ca74cb94f0e 100644 --- a/src/resources/filters/quarto-post/latexdiv.lua +++ b/src/resources/filters/quarto-post/latexdiv.lua @@ -34,7 +34,7 @@ function latexDiv() -- if the first and last div blocks are paragraphs then we can -- bring the environment begin/end closer to the content - if #divEl.content > 0 and divEl.content[1].t == "Para" and divEl.content[#divEl.content].t == "Para" then + if quarto.utils.match("[1]/Para")(divEl) and divEl.content[#divEl.content].t == "Para" then table.insert(divEl.content[1].content, 1, pandoc.RawInline('tex', beginEnv .. "\n")) table.insert(divEl.content[#divEl.content].content, pandoc.RawInline('tex', "\n" .. endEnv)) else diff --git a/tests/docs/smoke-all/crossrefs/theorem/proof-rendering-html.qmd b/tests/docs/smoke-all/crossrefs/theorem/proof-rendering-html.qmd new file mode 100644 index 00000000000..89d9286abf0 --- /dev/null +++ b/tests/docs/smoke-all/crossrefs/theorem/proof-rendering-html.qmd @@ -0,0 +1,35 @@ +--- +title: Proof rendering (HTML) +format: html +_quarto: + tests: + html: + noErrors: default + ensureHtmlElements: + - + - 'div.proof' + - '.proof-title' +--- + +## Proof with content + +::: {.proof} +This is a proof with content. +::: + +## Named proof + +::: {.proof name="Of the main theorem"} +Named proof body. +::: + +## Empty proof + +::: {.proof} +::: + +## Remark + +::: {.remark} +This is a remark. +::: diff --git a/tests/docs/smoke-all/crossrefs/theorem/proof-rendering.qmd b/tests/docs/smoke-all/crossrefs/theorem/proof-rendering.qmd new file mode 100644 index 00000000000..1d6efa6ad45 --- /dev/null +++ b/tests/docs/smoke-all/crossrefs/theorem/proof-rendering.qmd @@ -0,0 +1,38 @@ +--- +title: Proof rendering +format: pdf +keep-tex: true +_quarto: + tests: + pdf: + noErrors: default + ensureLatexFileRegexMatches: + - + - '\\begin\{proof\}' + - '\\end\{proof\}' + - '\\begin\{remark\}' + - '\\end\{remark\}' +--- + +## Proof with content + +::: {.proof} +This is a proof with content. +::: + +## Named proof + +::: {.proof name="Of the main theorem"} +Named proof body. +::: + +## Empty proof + +::: {.proof} +::: + +## Remark + +::: {.remark} +This is a remark. +::: From 3611b57b2fbf79233e3eb2db21cdf521612e6a1c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 10 Jun 2026 18:17:35 +0200 Subject: [PATCH 6/7] fix: use not-truthy check for match() return in pandoc3_figure quarto.utils.match() returns false (not nil) on no-match. The == nil check let the boolean false pass through to pandoc.Para({link}), crashing with "Inline-ish expected, got boolean" on figures without linked images (e.g. alt-text feature-format-matrix tests). --- src/resources/filters/layout/pandoc3_figure.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/filters/layout/pandoc3_figure.lua b/src/resources/filters/layout/pandoc3_figure.lua index 508509ee975..2ebb335fb96 100644 --- a/src/resources/filters/layout/pandoc3_figure.lua +++ b/src/resources/filters/layout/pandoc3_figure.lua @@ -12,7 +12,7 @@ function render_pandoc3_figure() local div = pandoc.Div({}) div.identifier = "fig-yesiamafigure" -- this is a bad hack to make discoverLinkedFigureDiv work local link = quarto.utils.match("[1]/Plain/[1]/Link")(figure) - if link == nil then + if not link then return nil end div.content:insert(pandoc.Para({link})) From 00f295355001ea04cff8da723ef5798c64ca40b0 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 10 Jun 2026 18:31:16 +0200 Subject: [PATCH 7/7] test: strengthen smoke-all assertions per review feedback - empty-latex-div: use distinct env (flushright) for empty div so regex uniquely verifies it instead of being satisfied by the non-empty case - proof-rendering: assert named proof title \begin{proof}[Of the main theorem] --- tests/docs/smoke-all/crossrefs/theorem/proof-rendering.qmd | 1 + tests/docs/smoke-all/latex/empty-latex-div.qmd | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/docs/smoke-all/crossrefs/theorem/proof-rendering.qmd b/tests/docs/smoke-all/crossrefs/theorem/proof-rendering.qmd index 1d6efa6ad45..ec401f26fb4 100644 --- a/tests/docs/smoke-all/crossrefs/theorem/proof-rendering.qmd +++ b/tests/docs/smoke-all/crossrefs/theorem/proof-rendering.qmd @@ -10,6 +10,7 @@ _quarto: - - '\\begin\{proof\}' - '\\end\{proof\}' + - '\\begin\{proof\}\[Of the main theorem\]' - '\\begin\{remark\}' - '\\end\{remark\}' --- diff --git a/tests/docs/smoke-all/latex/empty-latex-div.qmd b/tests/docs/smoke-all/latex/empty-latex-div.qmd index 961ec3a0f07..fc97fed664a 100644 --- a/tests/docs/smoke-all/latex/empty-latex-div.qmd +++ b/tests/docs/smoke-all/latex/empty-latex-div.qmd @@ -10,6 +10,8 @@ _quarto: - - '\\begin\{center\}' - '\\end\{center\}' + - '\\begin\{flushright\}' + - '\\end\{flushright\}' --- ## Non-empty LaTeX div @@ -20,5 +22,5 @@ Some centered content. ## Empty LaTeX div -::: {.center latex="true"} +::: {.flushright latex="true"} :::