Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/changelog-1.12.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/resources/filters/main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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',
Expand Down
91 changes: 91 additions & 0 deletions src/resources/filters/quarto-finalize/section-ids.lua
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions tests/docs/smoke-all/2026/11/09/section-wrapper-ids-dashboard.qmd
Original file line number Diff line number Diff line change
@@ -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.
:::
40 changes: 40 additions & 0 deletions tests/docs/smoke-all/2026/11/09/section-wrapper-ids-nomerge.qmd
Original file line number Diff line number Diff line change
@@ -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.
> :::
43 changes: 43 additions & 0 deletions tests/docs/smoke-all/2026/11/09/section-wrapper-ids-revealjs.qmd
Original file line number Diff line number Diff line change
@@ -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}
99 changes: 99 additions & 0 deletions tests/docs/smoke-all/2026/11/09/section-wrapper-ids.qmd
Original file line number Diff line number Diff line change
@@ -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)<sec-second>']
- ['\] <sec-second>']
---

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.
:::
Loading