From dc872217f350022cb08ced78f447b1246ab6bddc Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 10 Nov 2026 18:00:06 +0100 Subject: [PATCH 1/2] filters: keep heading id on sections Pandoc 3.13 merges with a wrapper div Pandoc 3.10.1+ merges a Div with an id that starts with a heading into the heading's section and gives the section the Div's id (jgm/pandoc#11745). In HTML the TOC, anchor links, search and the crossref preview then use the wrapper id while crossrefs use the heading id. Beamer, DocBook, ConTeXt and AsciiDoc drop the heading id entirely (jgm/pandoc#12070), and revealjs strips slide heading ids, so crossrefs to such headings dangle. A finalize filter swaps the two ids exactly where Pandoc merges, keeping the single section and its classes. It follows makeSections' own walk (Div nesting only, sections split by nested headings) and runs only for the formats where the merge changes ids: LaTeX, Typst and docx already write both ids, and a swap there moves the crossref label off the heading. --- src/resources/filters/main.lua | 5 + .../filters/quarto-finalize/section-ids.lua | 91 +++++++++++++++++ .../11/09/section-wrapper-ids-dashboard.qmd | 37 +++++++ .../11/09/section-wrapper-ids-nomerge.qmd | 40 ++++++++ .../11/09/section-wrapper-ids-revealjs.qmd | 43 ++++++++ .../2026/11/09/section-wrapper-ids.qmd | 99 +++++++++++++++++++ 6 files changed, 315 insertions(+) create mode 100644 src/resources/filters/quarto-finalize/section-ids.lua create mode 100644 tests/docs/smoke-all/2026/11/09/section-wrapper-ids-dashboard.qmd create mode 100644 tests/docs/smoke-all/2026/11/09/section-wrapper-ids-nomerge.qmd create mode 100644 tests/docs/smoke-all/2026/11/09/section-wrapper-ids-revealjs.qmd create mode 100644 tests/docs/smoke-all/2026/11/09/section-wrapper-ids.qmd diff --git a/src/resources/filters/main.lua b/src/resources/filters/main.lua index 3d4603344de..e9584c63ca8 100644 --- a/src/resources/filters/main.lua +++ b/src/resources/filters/main.lua @@ -100,6 +100,7 @@ import("./quarto-finalize/meta-cleanup.lua") -- import("./quarto-finalize/coalesceraw.lua") -- import("./quarto-finalize/descaffold.lua") import("./quarto-finalize/finalize-combined-1.lua") +import("./quarto-finalize/section-ids.lua") import("./quarto-finalize/typst.lua") import("./normalize/flags.lua") @@ -619,6 +620,10 @@ local quarto_finalize_filters = { filter = finalize_combined_1(), traverser = 'jog', }, + { name = "finalize-section-ids", + filter = section_ids(), + traverser = 'jog', + }, -- { name = "finalize-coalesce-raw", -- filters = coalesce_raw(), -- traverser = 'jog', diff --git a/src/resources/filters/quarto-finalize/section-ids.lua b/src/resources/filters/quarto-finalize/section-ids.lua new file mode 100644 index 00000000000..a6bf1cc72d8 --- /dev/null +++ b/src/resources/filters/quarto-finalize/section-ids.lua @@ -0,0 +1,91 @@ +-- section-ids.lua +-- +-- Pandoc >= 3.10.1 merges a Div with an id that starts with a heading into +-- the heading's section and gives the section the Div's id +-- (jgm/pandoc#11745). Swapping the two ids keeps the heading id, which is +-- the crossref target, on the section. +-- +-- Only formats where the merge changes ids are swapped: LaTeX, Typst and +-- docx write both ids, so a swap there would move the label off the +-- heading. The condition mirrors makeSections (Div nesting only, sections +-- split by later headings). The revealjs/beamer/docbook/context/asciidoc +-- group works around jgm/pandoc#12070 (writers dropping the heading id) +-- and Quarto's revealjs heading-attribute stripping; beamer, docbook, +-- context and asciidoc can leave it once a fixed Pandoc is bundled +-- (re-check their output first). +-- +-- Copyright (C) 2026 Posit Software, PBC + +function section_ids() + -- "swap_section": also swap Divs with class `section` + local function format_group() + local format = _quarto.format + if format.isRevealJsOutput() or format.isBeamerOutput() + or FORMAT:match("^docbook") or FORMAT == "context" + or format.isAsciiDocOutput() then + return "swap_section" + elseif format.isHtmlOutput() then + return "skip_section" + end + return nil + end + + -- pandoc's headerLtEq: a Header of level <= `level`, or a Div whose + -- first block is one (recursively) + local function lt_eq(block, level) + if block.t == "Header" then + return block.level <= level + elseif block.t == "Div" and block.content[1] then + return lt_eq(block.content[1], level) + end + return false + end + + local function should_swap(div, group) + local header = div.content[1] + if div.identifier == "" or not header or header.t ~= "Header" + or header.identifier == "" or header.identifier == div.identifier then + return false + end + for i = 2, #div.content do + if lt_eq(div.content[i], header.level) then + return false + end + end + if div.classes:includes("section") then + return group == "swap_section" + end + for _, class in ipairs({ "column", "columns", "fragment", "cell" }) do + if div.classes:includes(class) then + return false + end + end + return true + end + + return { + Pandoc = function(doc) + local group = format_group() + if group == nil then + return nil + end + local changed = false + local function walk(blocks) + for _, block in ipairs(blocks) do + if block.t == "Div" then + if should_swap(block, group) then + local header = block.content[1] + block.identifier, header.identifier = header.identifier, block.identifier + changed = true + end + walk(block.content) + end + end + end + walk(doc.blocks) + if changed then + return doc + end + end + } +end diff --git a/tests/docs/smoke-all/2026/11/09/section-wrapper-ids-dashboard.qmd b/tests/docs/smoke-all/2026/11/09/section-wrapper-ids-dashboard.qmd new file mode 100644 index 00000000000..b4f261f5077 --- /dev/null +++ b/tests/docs/smoke-all/2026/11/09/section-wrapper-ids-dashboard.qmd @@ -0,0 +1,37 @@ +--- +title: Dash +format: dashboard +_quarto: + tests: + dashboard: + ensureHtmlElements: + - [] + - ['.card .card'] +--- + +This is a characterization test, not a test of the section id filter. It +pins Pandoc 3.13's flat dashboard: wrapped rows and cards are not nested +inside other cards. Dashboard sections are `.section` Divs by finalize, so +the filter does not act here. + +## Row {height=40%} + +### Card A {#card-a} + +Alpha. + +::: {#wrapped-row} +## Row 2 + +### Card B + +Beta. +::: + +## Row 3 + +::: {#wrapped-card .myc} +### Card C {#card-c} + +Gamma. +::: diff --git a/tests/docs/smoke-all/2026/11/09/section-wrapper-ids-nomerge.qmd b/tests/docs/smoke-all/2026/11/09/section-wrapper-ids-nomerge.qmd new file mode 100644 index 00000000000..5b994c73951 --- /dev/null +++ b/tests/docs/smoke-all/2026/11/09/section-wrapper-ids-nomerge.qmd @@ -0,0 +1,40 @@ +--- +title: Section wrapper ids, no merge +format: html +_quarto: + tests: + html: + ensureHtmlElements: + - - 'div#outer > section#sec-outer' + - 'section#sec-inner > h2#inner' + - 'blockquote div#bq > h2#sec-q' + - - 'section#outer' + - 'div#sec-q' +--- + +Pandoc only merges a wrapper Div into its section when the heading is the +Div's first block and no later heading in the Div closes that section, and +only for Divs reached through Div nesting. This document pins three shapes +where Quarto's id swap must follow that rule: a nested same-level heading +splits `#outer` (no merge, no swap) while `#inner` merges (swapped), and a +Div inside a BlockQuote is never merged (no swap). + +## First {#sec-first} + +::: {#outer} +## Outer {#sec-outer} + +Outer text. + +::: {#inner} +## Inner {#sec-inner} + +Inner text. +::: +::: + +> ::: {#bq} +> ## InQuote {#sec-q} +> +> Quoted. +> ::: diff --git a/tests/docs/smoke-all/2026/11/09/section-wrapper-ids-revealjs.qmd b/tests/docs/smoke-all/2026/11/09/section-wrapper-ids-revealjs.qmd new file mode 100644 index 00000000000..08c4f1d3878 --- /dev/null +++ b/tests/docs/smoke-all/2026/11/09/section-wrapper-ids-revealjs.qmd @@ -0,0 +1,43 @@ +--- +title: Section wrapper ids, revealjs +format: revealjs +_quarto: + tests: + revealjs: + ensureHtmlElements: + - - 'section#sec-s2.slide.bg' + - 'section#sec-x.slide' + - 'a.quarto-xref[href="#/sec-s2"]' + - 'div#frag.fragment' + - - 'section#myslide' + - 'section#explicit' +--- + +Revealjs strips slide heading ids, so a crossref to a heading wrapped in an +id'd Div must resolve to the slide through the heading id moved onto the +section. This covers a plain wrapper, an explicit `.section` wrapper (also +swapped in revealjs), and a `.fragment` wrapper, which is never merged. + +## S1 {#sec-s1} + +See @sec-s2 and @sec-x. + +::: {#myslide .bg} +## S2 {#sec-s2} + +Content 2 + +::: {#frag .fragment} +### Sub {#sec-sub} + +Fragment text +::: +::: + +::: {#explicit .section} +## X {#sec-x} + +Content X +::: + +## S4 {#sec-s4} diff --git a/tests/docs/smoke-all/2026/11/09/section-wrapper-ids.qmd b/tests/docs/smoke-all/2026/11/09/section-wrapper-ids.qmd new file mode 100644 index 00000000000..5d50892c150 --- /dev/null +++ b/tests/docs/smoke-all/2026/11/09/section-wrapper-ids.qmd @@ -0,0 +1,99 @@ +--- +title: Section wrapper ids +number-sections: true +toc: true +format: + html: default + epub: default + typst: + keep-typ: true + asciidoc: default + docbook: + output-ext: xml + pdf: + keep-tex: true + beamer: + output-ext: tex + context: + output-ext: tex +_quarto: + tests: + html: + ensureHtmlElements: + - - 'section#sec-second.level2 > h2#sec-other' + - 'section#sec-third.level2.myclass' + - 'nav#TOC a[href="#sec-second"]' + - 'h2[data-anchor-id="sec-second"]' + - 'section#explicit > h2#sec-sixth' + - 'div#nomerge > section#sec-seventh' + - - 'section#sec-other' + - 'section#sec-sixth' + epub: + ensureEpubFileRegexMatches: + - path: EPUB/nav.xhtml + regexes: + - ['ch001\.xhtml#sec-second', 'ch001\.xhtml#sec-third'] + - ['ch001\.xhtml#sec-other'] + beamer: + ensureFileRegexMatches: + - ['\\label\{sec-second\}', '\\label\{sec-third\}', '\\label\{sec-sixth\}'] + - [] + docbook: + ensureFileRegexMatches: + - ['xml:id="sec-second"', 'xml:id="sec-third"', 'xml:id="sec-sixth"'] + - [] + context: + ensureFileRegexMatches: + - ['reference=\{sec-second\}', 'reference=\{sec-third\}', 'reference=\{sec-sixth\}'] + - [] + asciidoc: + ensureFileRegexMatches: + - ['\[\[sec-second\]\]', '\[\[sec-third\]\]', '\[\[sec-sixth\]\]'] + - [] + pdf: + ensureLatexFileRegexMatches: + - ['\\section\{Second\}\\label\{sec-second\}', '\\phantomsection\\label\{sec-other\}'] + - ['\\phantomsection\\label\{sec-second\}'] + typst: + ensureTypstFileRegexMatches: + - ['= Second(\r\n?|\n)'] + - ['\] '] +--- + +Pandoc 3.13 merges a Div with an id that starts with a heading into the +heading's section, giving the section the Div's id. This document checks +that Quarto keeps the heading id on the section (the crossref target) for +the formats where the merge would otherwise change ids, and leaves pdf and +typst alone because they write both ids. + +## First {#sec-first} + +See @sec-second, @sec-third and @sec-sixth. + +::: {#sec-other} +## Second {#sec-second} + +Body of second. +::: + +::: {#wrap3 .myclass} +## Third {#sec-third} + +Body of third. +::: + +::: {#explicit .section} +## Sixth {#sec-sixth} + +Explicit section div. +::: + +::: {#nomerge} +## Seventh {#sec-seventh} + +Seven. + +## Eighth {#sec-eighth} + +Eight. +::: From 2849f976d1da04aa7afb71b3ba47b0b5063602f8 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 10 Nov 2026 18:00:31 +0100 Subject: [PATCH 2/2] changelog: note Pandoc 3.13 section merge for id'd wrapper divs --- news/changelog-1.12.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.12.md b/news/changelog-1.12.md index 0d8456c5981..77f69385bbf 100644 --- a/news/changelog-1.12.md +++ b/news/changelog-1.12.md @@ -14,6 +14,7 @@ All changes included in 1.12: - ([jgm/pandoc#11845](https://github.com/jgm/pandoc/issues/11845)): In Word output, cross-reference targets are now hidden bookmarks with generated names instead of their Quarto id (`fig-plot`). Cross-reference links inside the document still work, but a link or script that looks a bookmark up by its Quarto id from outside the document, such as `report.docx#fig-plot` or `officer`, VBA or `python-docx` code, no longer finds it. - ([jgm/pandoc 3.13](https://github.com/jgm/pandoc/releases/tag/3.13)): A YAML bibliography file with a duplicated key, such as two `title` fields in one reference, now stops the render with an error giving the file, line and column. Before, the last value was kept silently. Remove the duplicate key to render again. - ([jgm/pandoc@c73a35a](https://github.com/jgm/pandoc/commit/c73a35a2e7ed1144132f1e533b4f0ae4c3648143)): Support Pandoc's `--math-method` option in `quarto render`, so that `--math-method=katex` works with `--embed-resources` and keeps math intact in `llms.md` output, like `--katex` does. + - ([jgm/pandoc#11745](https://github.com/jgm/pandoc/issues/11745)): A Div with an id that starts with a heading now renders as a single section instead of a div around a section, and the wrapper's classes move onto that section (CSS such as `div.myclass > section` no longer matches). Revealjs now shows such slides and dashboards no longer nest such rows or cards. In HTML, EPUB, revealjs, beamer, DocBook, ConTeXt and AsciiDoc, Quarto keeps the heading id on the section, so crossrefs and table of contents links still target the heading; in beamer, DocBook, ConTeXt, AsciiDoc and revealjs the wrapper id is no longer written. An explicit `.section` wrapper in HTML follows Pandoc's new behavior: the section takes the wrapper id. ## Accessibility