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.9.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ All changes included in 1.9:
- PDF accessibility metadata: document title, author, and keywords are now set for PDF readers.
- Two-column layout now uses `set page(columns:)` instead of `columns()` function, fixing compatibility with landscape sections.
- Title block now properly spans both columns in multi-column layouts.
- ([#13870](https://github.com/quarto-dev/quarto-cli/issues/13870)): Add support for `alt` attribute on cross-referenced equations for improved accessibility. (author: @mcanouil)

### `pdf`

Expand Down
224 changes: 178 additions & 46 deletions src/resources/filters/crossref/equations.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
-- equations.lua
-- Copyright (C) 2020-2022 Posit Software, PBC
-- Copyright (C) 2020-2026 Posit Software, PBC

-- process all equations
function equations()
Expand All @@ -21,67 +21,58 @@ function process_equations(blockEl)

local mathInlines = nil
local targetInlines = pandoc.Inlines{}
local skipUntil = 0

for i, el in ipairs(inlines) do

-- see if we need special handling for pending math, if
-- we do then track whether we should still process the
-- inline at the end of the loop
local processInline = true

-- Skip elements that were consumed as part of a multi-element attribute block
if i <= skipUntil then
processInline = false
goto continue
end
if mathInlines then
if el.t == "Space" then
mathInlines:insert(el)
processInline = false
elseif el.t == "Str" and refLabel("eq", el) then

-- add to the index
local label = refLabel("eq", el)
local order = indexNextOrder("eq")
indexAddEntry(label, nil, order)

-- get the equation
local eq = mathInlines[1]

-- write equation
if _quarto.format.isLatexOutput() then
targetInlines:insert(pandoc.RawInline("latex", "\\begin{equation}"))
targetInlines:insert(pandoc.Span(pandoc.RawInline("latex", eq.text), pandoc.Attr(label)))

-- Pandoc 3.1.7 started outputting a shadow section with a label as a link target
-- which would result in two identical labels being emitted.
-- https://github.com/jgm/pandoc/issues/9045
-- https://github.com/lierdakil/pandoc-crossref/issues/402
targetInlines:insert(pandoc.RawInline("latex", "\\end{equation}"))

elseif _quarto.format.isTypstOutput() then
local is_block = eq.mathtype == "DisplayMath" and "true" or "false"
targetInlines:insert(pandoc.RawInline("typst",
"#math.equation(block: " .. is_block .. ", numbering: \"(1)\", " ..
"[ "))
targetInlines:insert(eq)
targetInlines:insert(pandoc.RawInline("typst", " ])<" .. label .. ">"))
else
local eqNumber = eqQquad
local mathMethod = param("html-math-method", nil)
if type(mathMethod) == "table" and mathMethod["method"] then
mathMethod = mathMethod["method"]
end
if _quarto.format.isHtmlOutput() and (mathMethod == "mathjax" or mathMethod == "katex") then
eqNumber = eqTag
-- Check "starts with" not complete match: Pandoc splits {#eq-label alt="..."} across elements
elseif el.t == "Str" and el.text:match("^{#eq%-") then
-- Collect attribute block: {#eq-label alt="..."} may span multiple elements
local attrText, consumed = collectAttrBlock(inlines, i)

if attrText then
-- Parse to extract label and optional attributes (e.g., alt for Typst)
local label, attributes = parseRefAttr(attrText)
if not label then
local _, extracted = extractRefLabel("eq", attrText)
label = extracted
end
eq.text = eq.text .. " " .. eqNumber(inlinesToString(numberOption("eq", order)))
local span = pandoc.Span(eq, pandoc.Attr(label))
targetInlines:insert(span)
end

-- reset state
mathInlines = nil
processInline = false
local order = indexNextOrder("eq")
indexAddEntry(label, nil, order)

local eq = mathInlines[1]
local alt = attributes and attributes["alt"] or nil
local eqInlines = renderEquation(eq, label, alt, order)
targetInlines:extend(eqInlines)

-- Skip consumed elements and reset state
skipUntil = i + consumed - 1
mathInlines = nil
processInline = false
else
targetInlines:extend(mathInlines)
mathInlines = nil
end
else
targetInlines:extend(mathInlines)
mathInlines = nil
end
end
::continue::

-- process the inline unless it was already taken care of above
if processInline then
Expand All @@ -103,7 +94,54 @@ function process_equations(blockEl)
-- return the processed list
blockEl.content = targetInlines
return blockEl


end

-- Render equation output for all formats.
-- The alt parameter is only used for Typst output (accessibility).
function renderEquation(eq, label, alt, order)
local result = pandoc.Inlines{}

if _quarto.format.isLatexOutput() then
result:insert(pandoc.RawInline("latex", "\\begin{equation}"))
result:insert(pandoc.Span(pandoc.RawInline("latex", eq.text), pandoc.Attr(label)))

-- Pandoc 3.1.7 started outputting a shadow section with a label as a link target
-- which would result in two identical labels being emitted.
-- https://github.com/jgm/pandoc/issues/9045
-- https://github.com/lierdakil/pandoc-crossref/issues/402
result:insert(pandoc.RawInline("latex", "\\end{equation}"))

elseif _quarto.format.isTypstOutput() then
local is_block = eq.mathtype == "DisplayMath" and "true" or "false"
-- Escape quotes in alt text for Typst string literal
-- First normalize curly quotes to straight quotes (Pandoc may apply smart quotes)
local alt_param = ""
if alt then
local escaped_alt = alt:gsub("“", '"'):gsub("”", '"')
escaped_alt = escaped_alt:gsub("‘", "'"):gsub("’", "'")
escaped_alt = escaped_alt:gsub('"', '\\"')
alt_param = ", alt: \"" .. escaped_alt .. "\""
end
result:insert(pandoc.RawInline("typst",
"#math.equation(block: " .. is_block .. ", numbering: \"(1)\"" .. alt_param .. ", [ "))
result:insert(eq)
result:insert(pandoc.RawInline("typst", " ])<" .. label .. ">"))

else
local eqNumber = eqQquad
local mathMethod = param("html-math-method", nil)
if type(mathMethod) == "table" and mathMethod["method"] then
mathMethod = mathMethod["method"]
end
if _quarto.format.isHtmlOutput() and (mathMethod == "mathjax" or mathMethod == "katex") then
eqNumber = eqTag
end
eq.text = eq.text .. " " .. eqNumber(inlinesToString(numberOption("eq", order)))
result:insert(pandoc.Span(eq, pandoc.Attr(label)))
end

return result
end

function eqTag(eq)
Expand All @@ -117,3 +155,97 @@ end
function isDisplayMath(el)
return el.t == "Math" and el.mathtype == "DisplayMath"
end


-- Collect a complete attribute block from inline elements.
--
-- Pandoc tokenises `{#eq-label alt="description"}` into multiple elements:
-- Str "{#eq-label", Space, Str "alt=", Quoted [...], Str "}"
--
-- This function reassembles these elements into a single string for parseRefAttr().
-- Quoted elements are reconstructed with escaped inner quotes to preserve the
-- original attribute syntax.
--
-- Returns: collected text (string), number of elements consumed (number)
function collectAttrBlock(inlines, startIndex)
local first = inlines[startIndex]
if not first or first.t ~= "Str" then
return nil, 0
end

local collected = first.text
local consumed = 1

if collected:match("}$") then
return collected, consumed
end

for j = startIndex + 1, #inlines do
local el = inlines[j]
if el.t == "Str" then
collected = collected .. el.text
consumed = consumed + 1
elseif el.t == "Space" then
collected = collected .. " "
consumed = consumed + 1
elseif el.t == "Quoted" then
local quote = el.quotetype == "DoubleQuote" and '"' or "'"
local content = pandoc.utils.stringify(el.content)
if el.quotetype == "DoubleQuote" then
content = content:gsub('"', '\\"')
else
content = content:gsub("'", "\\'")
end
collected = collected .. quote .. content .. quote
consumed = consumed + 1
else
break
end
if collected:match("}$") then
break
end
end

if collected:match("^{#eq%-[^}]+}$") then
return collected, consumed
end

return nil, 0
end


-- Parse a Pandoc attribute block string into identifier and attributes.
--
-- Uses pandoc.read() with a dummy header to leverage Pandoc's native attribute
-- parser, avoiding fragile regex-based parsing.
--
-- Single-quoted attributes (e.g., alt='text') must be converted to double quotes
-- because Pandoc's attribute syntax only supports double-quoted values.
-- The conversion uses a three-step process:
-- 1. Protect escaped single quotes (\') with a placeholder.
-- 2. Convert key='value' to key="value", escaping any internal double quotes.
-- 3. Restore any remaining placeholders to literal single quotes.
--
-- Returns: identifier (string), attributes (table)
function parseRefAttr(text)
if not text then return nil, nil end

local placeholder = "\x00ESC_SQUOTE\x00"
text = text:gsub("\\'", placeholder)
text = text:gsub("(%w+)='([^']*)'", function(key, value)
value = value:gsub(placeholder, "'")
value = value:gsub('"', '\\"')
return key .. '="' .. value .. '"'
end)
text = text:gsub(placeholder, "'")

-- Normalise spaces around = in attributes (alt = "value" -> alt="value")
text = text:gsub("(%w+)%s*=%s*(['\"])", "%1=%2")

local parsed = pandoc.read("## " .. text, "markdown")
if parsed and parsed.blocks[1] and parsed.blocks[1].attr then
local attr = parsed.blocks[1].attr
return attr.identifier, attr.attributes
end
return nil, nil
end
114 changes: 114 additions & 0 deletions tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
title: Equation Alt-Text Test
format:
html: default
typst:
keep-typ: true
pdf:
keep-tex: true
_quarto:
tests:
html:
ensureHtmlElements:
-
- "span#eq-display-math > span.math"
- "span#eq-display-alt > span.math"
- "span#eq-display-alt-spaces > span.math"
- "span#eq-single-quote > span.math"
- "span#eq-double-quote > span.math"
- "span#eq-mixed-quotes > span.math"
- "span#eq-single-quote-alt > span.math"
- "a.quarto-xref[href='#eq-display-math']"
- "a.quarto-xref[href='#eq-display-alt']"
- "a.quarto-xref[href='#eq-display-alt-spaces']"
- "a.quarto-xref[href='#eq-single-quote']"
- "a.quarto-xref[href='#eq-double-quote']"
- "a.quarto-xref[href='#eq-mixed-quotes']"
- "a.quarto-xref[href='#eq-single-quote-alt']"
- []
pdf:
ensureLatexFileRegexMatches:
-
- "\\\\begin\\{equation\\}\\\\protect\\\\phantomsection\\\\label\\{eq-display-math\\}"
- "\\\\begin\\{equation\\}\\\\protect\\\\phantomsection\\\\label\\{eq-display-alt\\}"
- "\\\\begin\\{equation\\}\\\\protect\\\\phantomsection\\\\label\\{eq-display-alt-spaces\\}"
- "\\\\begin\\{equation\\}\\\\protect\\\\phantomsection\\\\label\\{eq-single-quote\\}"
- "\\\\begin\\{equation\\}\\\\protect\\\\phantomsection\\\\label\\{eq-double-quote\\}"
- "\\\\begin\\{equation\\}\\\\protect\\\\phantomsection\\\\label\\{eq-mixed-quotes\\}"
- "\\\\begin\\{equation\\}\\\\protect\\\\phantomsection\\\\label\\{eq-single-quote-alt\\}"
typst:
ensureTypstFileRegexMatches:
-
- "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", \\[ \\$ E = m c\\^2 \\$ \\]\\)<eq-display-math>"
- "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"Einsteins mass-energy equivalence equation\", \\[ \\$ E = m c\\^2 \\$ \\]\\)<eq-display-alt>"
- "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"Einsteins mass-energy equivalence equation\", \\[ \\$ E = m c\\^2 \\$ \\]\\)<eq-display-alt-spaces>"
- "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"Newton's second law of motion\", \\[ \\$ F = m a \\$ \\]\\)<eq-single-quote>"
- "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"The \\\\\"Pythagorean\\\\\" theorem\", \\[ \\$ a\\^2 \\+ b\\^2 = c\\^2 \\$ \\]\\)<eq-double-quote>"
- "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"This is using \\\\\"quotes\\\\\" but I'm sure it works\", \\[ \\$ x \\+ y = z \\$ \\]\\)<eq-mixed-quotes>"
- "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"This is using 'single quotes' around the \\\\\"quotes\\\\\" but I'm sure it works\", \\[ \\$ x \\+ y = z \\$ \\]\\)<eq-single-quote-alt>"
- []
---

## Inline Math (no label)

This is an inline equation: $E = mc^2$.

## Display Math (no label)

$$
a^2 + b^2 = c^2
$$

## Display Math (with label, no alt)

$$
E = mc^2
$$ {#eq-display-math}

See @eq-display-math.

## Display Math (with label and alt)

$$
E = mc^2
$$ {#eq-display-alt alt="Einsteins mass-energy equivalence equation"}

See @eq-display-alt.

$$
E = mc^2
$$ {#eq-display-alt-spaces alt = "Einsteins mass-energy equivalence equation"}

See @eq-display-alt-spaces.

## Display Math (with single quote in alt)

$$
F = ma
$$ {#eq-single-quote alt="Newton's second law of motion"}

See @eq-single-quote.

## Display Math (with double quotes in alt)

$$
a^2 + b^2 = c^2
$$ {#eq-double-quote alt='The "Pythagorean" theorem'}

See @eq-double-quote.

## Display Math (with mixed quotes in alt)

$$
x + y = z
$$ {#eq-mixed-quotes alt="This is using \"quotes\" but I'm sure it works"}

See @eq-mixed-quotes.

## Display Math (with single quotes in and around alt)

$$
x + y = z
$$ {#eq-single-quote-alt alt='This is using \'single quotes\' around the "quotes" but I\'m sure it works'}

See @eq-single-quote-alt.
Loading