Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
220 changes: 174 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,57 @@ 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
label = extractRefLabel("eq", attrText)
Comment thread
mcanouil marked this conversation as resolved.
Outdated
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 +93,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 +154,94 @@ 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, "'")

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
104 changes: 104 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,104 @@
---
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-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-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-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: \"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.

## 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