Skip to content

Commit a678464

Browse files
authored
feat(equations.lua): support alt attribute for cross-referenced equations (#13889)
Enhance accessibility by allowing the use of the alt attribute for cross-referenced equations for Typst format(s). It does not yet add support in LaTeX or HTML format.
1 parent 03dc6fb commit a678464

3 files changed

Lines changed: 293 additions & 46 deletions

File tree

news/changelog-1.9.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ All changes included in 1.9:
5151
- PDF accessibility metadata: document title, author, and keywords are now set for PDF readers.
5252
- Two-column layout now uses `set page(columns:)` instead of `columns()` function, fixing compatibility with landscape sections.
5353
- Title block now properly spans both columns in multi-column layouts.
54+
- ([#13870](https://github.com/quarto-dev/quarto-cli/issues/13870)): Add support for `alt` attribute on cross-referenced equations for improved accessibility. (author: @mcanouil)
5455

5556
### `pdf`
5657

Lines changed: 178 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
-- equations.lua
2-
-- Copyright (C) 2020-2022 Posit Software, PBC
2+
-- Copyright (C) 2020-2026 Posit Software, PBC
33

44
-- process all equations
55
function equations()
@@ -21,67 +21,58 @@ function process_equations(blockEl)
2121

2222
local mathInlines = nil
2323
local targetInlines = pandoc.Inlines{}
24+
local skipUntil = 0
2425

2526
for i, el in ipairs(inlines) do
26-
2727
-- see if we need special handling for pending math, if
2828
-- we do then track whether we should still process the
2929
-- inline at the end of the loop
3030
local processInline = true
31+
32+
-- Skip elements that were consumed as part of a multi-element attribute block
33+
if i <= skipUntil then
34+
processInline = false
35+
goto continue
36+
end
3137
if mathInlines then
3238
if el.t == "Space" then
3339
mathInlines:insert(el)
3440
processInline = false
35-
elseif el.t == "Str" and refLabel("eq", el) then
36-
37-
-- add to the index
38-
local label = refLabel("eq", el)
39-
local order = indexNextOrder("eq")
40-
indexAddEntry(label, nil, order)
41-
42-
-- get the equation
43-
local eq = mathInlines[1]
44-
45-
-- write equation
46-
if _quarto.format.isLatexOutput() then
47-
targetInlines:insert(pandoc.RawInline("latex", "\\begin{equation}"))
48-
targetInlines:insert(pandoc.Span(pandoc.RawInline("latex", eq.text), pandoc.Attr(label)))
49-
50-
-- Pandoc 3.1.7 started outputting a shadow section with a label as a link target
51-
-- which would result in two identical labels being emitted.
52-
-- https://github.com/jgm/pandoc/issues/9045
53-
-- https://github.com/lierdakil/pandoc-crossref/issues/402
54-
targetInlines:insert(pandoc.RawInline("latex", "\\end{equation}"))
55-
56-
elseif _quarto.format.isTypstOutput() then
57-
local is_block = eq.mathtype == "DisplayMath" and "true" or "false"
58-
targetInlines:insert(pandoc.RawInline("typst",
59-
"#math.equation(block: " .. is_block .. ", numbering: \"(1)\", " ..
60-
"[ "))
61-
targetInlines:insert(eq)
62-
targetInlines:insert(pandoc.RawInline("typst", " ])<" .. label .. ">"))
63-
else
64-
local eqNumber = eqQquad
65-
local mathMethod = param("html-math-method", nil)
66-
if type(mathMethod) == "table" and mathMethod["method"] then
67-
mathMethod = mathMethod["method"]
68-
end
69-
if _quarto.format.isHtmlOutput() and (mathMethod == "mathjax" or mathMethod == "katex") then
70-
eqNumber = eqTag
41+
-- Check "starts with" not complete match: Pandoc splits {#eq-label alt="..."} across elements
42+
elseif el.t == "Str" and el.text:match("^{#eq%-") then
43+
-- Collect attribute block: {#eq-label alt="..."} may span multiple elements
44+
local attrText, consumed = collectAttrBlock(inlines, i)
45+
46+
if attrText then
47+
-- Parse to extract label and optional attributes (e.g., alt for Typst)
48+
local label, attributes = parseRefAttr(attrText)
49+
if not label then
50+
local _, extracted = extractRefLabel("eq", attrText)
51+
label = extracted
7152
end
72-
eq.text = eq.text .. " " .. eqNumber(inlinesToString(numberOption("eq", order)))
73-
local span = pandoc.Span(eq, pandoc.Attr(label))
74-
targetInlines:insert(span)
75-
end
7653

77-
-- reset state
78-
mathInlines = nil
79-
processInline = false
54+
local order = indexNextOrder("eq")
55+
indexAddEntry(label, nil, order)
56+
57+
local eq = mathInlines[1]
58+
local alt = attributes and attributes["alt"] or nil
59+
local eqInlines = renderEquation(eq, label, alt, order)
60+
targetInlines:extend(eqInlines)
61+
62+
-- Skip consumed elements and reset state
63+
skipUntil = i + consumed - 1
64+
mathInlines = nil
65+
processInline = false
66+
else
67+
targetInlines:extend(mathInlines)
68+
mathInlines = nil
69+
end
8070
else
8171
targetInlines:extend(mathInlines)
8272
mathInlines = nil
8373
end
8474
end
75+
::continue::
8576

8677
-- process the inline unless it was already taken care of above
8778
if processInline then
@@ -103,7 +94,54 @@ function process_equations(blockEl)
10394
-- return the processed list
10495
blockEl.content = targetInlines
10596
return blockEl
106-
97+
98+
end
99+
100+
-- Render equation output for all formats.
101+
-- The alt parameter is only used for Typst output (accessibility).
102+
function renderEquation(eq, label, alt, order)
103+
local result = pandoc.Inlines{}
104+
105+
if _quarto.format.isLatexOutput() then
106+
result:insert(pandoc.RawInline("latex", "\\begin{equation}"))
107+
result:insert(pandoc.Span(pandoc.RawInline("latex", eq.text), pandoc.Attr(label)))
108+
109+
-- Pandoc 3.1.7 started outputting a shadow section with a label as a link target
110+
-- which would result in two identical labels being emitted.
111+
-- https://github.com/jgm/pandoc/issues/9045
112+
-- https://github.com/lierdakil/pandoc-crossref/issues/402
113+
result:insert(pandoc.RawInline("latex", "\\end{equation}"))
114+
115+
elseif _quarto.format.isTypstOutput() then
116+
local is_block = eq.mathtype == "DisplayMath" and "true" or "false"
117+
-- Escape quotes in alt text for Typst string literal
118+
-- First normalize curly quotes to straight quotes (Pandoc may apply smart quotes)
119+
local alt_param = ""
120+
if alt then
121+
local escaped_alt = alt:gsub("", '"'):gsub("", '"')
122+
escaped_alt = escaped_alt:gsub("", "'"):gsub("", "'")
123+
escaped_alt = escaped_alt:gsub('"', '\\"')
124+
alt_param = ", alt: \"" .. escaped_alt .. "\""
125+
end
126+
result:insert(pandoc.RawInline("typst",
127+
"#math.equation(block: " .. is_block .. ", numbering: \"(1)\"" .. alt_param .. ", [ "))
128+
result:insert(eq)
129+
result:insert(pandoc.RawInline("typst", " ])<" .. label .. ">"))
130+
131+
else
132+
local eqNumber = eqQquad
133+
local mathMethod = param("html-math-method", nil)
134+
if type(mathMethod) == "table" and mathMethod["method"] then
135+
mathMethod = mathMethod["method"]
136+
end
137+
if _quarto.format.isHtmlOutput() and (mathMethod == "mathjax" or mathMethod == "katex") then
138+
eqNumber = eqTag
139+
end
140+
eq.text = eq.text .. " " .. eqNumber(inlinesToString(numberOption("eq", order)))
141+
result:insert(pandoc.Span(eq, pandoc.Attr(label)))
142+
end
143+
144+
return result
107145
end
108146

109147
function eqTag(eq)
@@ -117,3 +155,97 @@ end
117155
function isDisplayMath(el)
118156
return el.t == "Math" and el.mathtype == "DisplayMath"
119157
end
158+
159+
160+
-- Collect a complete attribute block from inline elements.
161+
--
162+
-- Pandoc tokenises `{#eq-label alt="description"}` into multiple elements:
163+
-- Str "{#eq-label", Space, Str "alt=", Quoted [...], Str "}"
164+
--
165+
-- This function reassembles these elements into a single string for parseRefAttr().
166+
-- Quoted elements are reconstructed with escaped inner quotes to preserve the
167+
-- original attribute syntax.
168+
--
169+
-- Returns: collected text (string), number of elements consumed (number)
170+
function collectAttrBlock(inlines, startIndex)
171+
local first = inlines[startIndex]
172+
if not first or first.t ~= "Str" then
173+
return nil, 0
174+
end
175+
176+
local collected = first.text
177+
local consumed = 1
178+
179+
if collected:match("}$") then
180+
return collected, consumed
181+
end
182+
183+
for j = startIndex + 1, #inlines do
184+
local el = inlines[j]
185+
if el.t == "Str" then
186+
collected = collected .. el.text
187+
consumed = consumed + 1
188+
elseif el.t == "Space" then
189+
collected = collected .. " "
190+
consumed = consumed + 1
191+
elseif el.t == "Quoted" then
192+
local quote = el.quotetype == "DoubleQuote" and '"' or "'"
193+
local content = pandoc.utils.stringify(el.content)
194+
if el.quotetype == "DoubleQuote" then
195+
content = content:gsub('"', '\\"')
196+
else
197+
content = content:gsub("'", "\\'")
198+
end
199+
collected = collected .. quote .. content .. quote
200+
consumed = consumed + 1
201+
else
202+
break
203+
end
204+
if collected:match("}$") then
205+
break
206+
end
207+
end
208+
209+
if collected:match("^{#eq%-[^}]+}$") then
210+
return collected, consumed
211+
end
212+
213+
return nil, 0
214+
end
215+
216+
217+
-- Parse a Pandoc attribute block string into identifier and attributes.
218+
--
219+
-- Uses pandoc.read() with a dummy header to leverage Pandoc's native attribute
220+
-- parser, avoiding fragile regex-based parsing.
221+
--
222+
-- Single-quoted attributes (e.g., alt='text') must be converted to double quotes
223+
-- because Pandoc's attribute syntax only supports double-quoted values.
224+
-- The conversion uses a three-step process:
225+
-- 1. Protect escaped single quotes (\') with a placeholder.
226+
-- 2. Convert key='value' to key="value", escaping any internal double quotes.
227+
-- 3. Restore any remaining placeholders to literal single quotes.
228+
--
229+
-- Returns: identifier (string), attributes (table)
230+
function parseRefAttr(text)
231+
if not text then return nil, nil end
232+
233+
local placeholder = "\x00ESC_SQUOTE\x00"
234+
text = text:gsub("\\'", placeholder)
235+
text = text:gsub("(%w+)='([^']*)'", function(key, value)
236+
value = value:gsub(placeholder, "'")
237+
value = value:gsub('"', '\\"')
238+
return key .. '="' .. value .. '"'
239+
end)
240+
text = text:gsub(placeholder, "'")
241+
242+
-- Normalise spaces around = in attributes (alt = "value" -> alt="value")
243+
text = text:gsub("(%w+)%s*=%s*(['\"])", "%1=%2")
244+
245+
local parsed = pandoc.read("## " .. text, "markdown")
246+
if parsed and parsed.blocks[1] and parsed.blocks[1].attr then
247+
local attr = parsed.blocks[1].attr
248+
return attr.identifier, attr.attributes
249+
end
250+
return nil, nil
251+
end
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
---
2+
title: Equation Alt-Text Test
3+
format:
4+
html: default
5+
typst:
6+
keep-typ: true
7+
pdf:
8+
keep-tex: true
9+
_quarto:
10+
tests:
11+
html:
12+
ensureHtmlElements:
13+
-
14+
- "span#eq-display-math > span.math"
15+
- "span#eq-display-alt > span.math"
16+
- "span#eq-display-alt-spaces > span.math"
17+
- "span#eq-single-quote > span.math"
18+
- "span#eq-double-quote > span.math"
19+
- "span#eq-mixed-quotes > span.math"
20+
- "span#eq-single-quote-alt > span.math"
21+
- "a.quarto-xref[href='#eq-display-math']"
22+
- "a.quarto-xref[href='#eq-display-alt']"
23+
- "a.quarto-xref[href='#eq-display-alt-spaces']"
24+
- "a.quarto-xref[href='#eq-single-quote']"
25+
- "a.quarto-xref[href='#eq-double-quote']"
26+
- "a.quarto-xref[href='#eq-mixed-quotes']"
27+
- "a.quarto-xref[href='#eq-single-quote-alt']"
28+
- []
29+
pdf:
30+
ensureLatexFileRegexMatches:
31+
-
32+
- "\\\\begin\\{equation\\}\\\\protect\\\\phantomsection\\\\label\\{eq-display-math\\}"
33+
- "\\\\begin\\{equation\\}\\\\protect\\\\phantomsection\\\\label\\{eq-display-alt\\}"
34+
- "\\\\begin\\{equation\\}\\\\protect\\\\phantomsection\\\\label\\{eq-display-alt-spaces\\}"
35+
- "\\\\begin\\{equation\\}\\\\protect\\\\phantomsection\\\\label\\{eq-single-quote\\}"
36+
- "\\\\begin\\{equation\\}\\\\protect\\\\phantomsection\\\\label\\{eq-double-quote\\}"
37+
- "\\\\begin\\{equation\\}\\\\protect\\\\phantomsection\\\\label\\{eq-mixed-quotes\\}"
38+
- "\\\\begin\\{equation\\}\\\\protect\\\\phantomsection\\\\label\\{eq-single-quote-alt\\}"
39+
typst:
40+
ensureTypstFileRegexMatches:
41+
-
42+
- "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", \\[ \\$ E = m c\\^2 \\$ \\]\\)<eq-display-math>"
43+
- "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"Einsteins mass-energy equivalence equation\", \\[ \\$ E = m c\\^2 \\$ \\]\\)<eq-display-alt>"
44+
- "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"Einsteins mass-energy equivalence equation\", \\[ \\$ E = m c\\^2 \\$ \\]\\)<eq-display-alt-spaces>"
45+
- "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"Newton's second law of motion\", \\[ \\$ F = m a \\$ \\]\\)<eq-single-quote>"
46+
- "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"The \\\\\"Pythagorean\\\\\" theorem\", \\[ \\$ a\\^2 \\+ b\\^2 = c\\^2 \\$ \\]\\)<eq-double-quote>"
47+
- "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"This is using \\\\\"quotes\\\\\" but I'm sure it works\", \\[ \\$ x \\+ y = z \\$ \\]\\)<eq-mixed-quotes>"
48+
- "#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>"
49+
- []
50+
---
51+
52+
## Inline Math (no label)
53+
54+
This is an inline equation: $E = mc^2$.
55+
56+
## Display Math (no label)
57+
58+
$$
59+
a^2 + b^2 = c^2
60+
$$
61+
62+
## Display Math (with label, no alt)
63+
64+
$$
65+
E = mc^2
66+
$$ {#eq-display-math}
67+
68+
See @eq-display-math.
69+
70+
## Display Math (with label and alt)
71+
72+
$$
73+
E = mc^2
74+
$$ {#eq-display-alt alt="Einsteins mass-energy equivalence equation"}
75+
76+
See @eq-display-alt.
77+
78+
$$
79+
E = mc^2
80+
$$ {#eq-display-alt-spaces alt = "Einsteins mass-energy equivalence equation"}
81+
82+
See @eq-display-alt-spaces.
83+
84+
## Display Math (with single quote in alt)
85+
86+
$$
87+
F = ma
88+
$$ {#eq-single-quote alt="Newton's second law of motion"}
89+
90+
See @eq-single-quote.
91+
92+
## Display Math (with double quotes in alt)
93+
94+
$$
95+
a^2 + b^2 = c^2
96+
$$ {#eq-double-quote alt='The "Pythagorean" theorem'}
97+
98+
See @eq-double-quote.
99+
100+
## Display Math (with mixed quotes in alt)
101+
102+
$$
103+
x + y = z
104+
$$ {#eq-mixed-quotes alt="This is using \"quotes\" but I'm sure it works"}
105+
106+
See @eq-mixed-quotes.
107+
108+
## Display Math (with single quotes in and around alt)
109+
110+
$$
111+
x + y = z
112+
$$ {#eq-single-quote-alt alt='This is using \'single quotes\' around the "quotes" but I\'m sure it works'}
113+
114+
See @eq-single-quote-alt.

0 commit comments

Comments
 (0)