From cbf0e2221094d7f36ea55031ef1e9c623affda34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:25:35 +0100 Subject: [PATCH 1/5] feat(equations.lua): support alt attribute for cross-referenced equations --- news/changelog-1.9.md | 1 + src/resources/filters/crossref/equations.lua | 220 ++++++++++++++---- .../crossrefs/equations/equations-alt.qmd | 104 +++++++++ 3 files changed, 279 insertions(+), 46 deletions(-) create mode 100644 tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 14f341aa6d9..614202c1170 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -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` diff --git a/src/resources/filters/crossref/equations.lua b/src/resources/filters/crossref/equations.lua index 00431e11a16..6f7e9b6ea08 100644 --- a/src/resources/filters/crossref/equations.lua +++ b/src/resources/filters/crossref/equations.lua @@ -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() @@ -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) 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 @@ -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) @@ -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 diff --git a/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd b/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd new file mode 100644 index 00000000000..4255def4ebb --- /dev/null +++ b/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd @@ -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 \\$ \\]\\)" + - "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"Einsteins mass-energy equivalence equation\", \\[ \\$ E = m c\\^2 \\$ \\]\\)" + - "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"Newton's second law of motion\", \\[ \\$ F = m a \\$ \\]\\)" + - "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"The \\\\\"Pythagorean\\\\\" theorem\", \\[ \\$ a\\^2 \\+ b\\^2 = c\\^2 \\$ \\]\\)" + - "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"This is using \\\\\"quotes\\\\\" but I'm sure it works\", \\[ \\$ x \\+ y = z \\$ \\]\\)" + - "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"This is using 'single quotes' around the \\\\\"quotes\\\\\" but I'm sure it works\", \\[ \\$ x \\+ y = z \\$ \\]\\)" + - [] +--- + +## 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. From 45f314bc6199be70cd05901bf5df2258d6030c65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:11:41 +0100 Subject: [PATCH 2/5] fix: wrong order Co-authored-by: Christophe Dervieux --- src/resources/filters/crossref/equations.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/resources/filters/crossref/equations.lua b/src/resources/filters/crossref/equations.lua index 6f7e9b6ea08..92efee030a0 100644 --- a/src/resources/filters/crossref/equations.lua +++ b/src/resources/filters/crossref/equations.lua @@ -47,7 +47,8 @@ function process_equations(blockEl) -- 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) + local _, extracted = extractRefLabel("eq", attrText) + label = extracted end local order = indexNextOrder("eq") From 4691866409fccc72597d3d4badc377fba8d1450f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:21:24 +0100 Subject: [PATCH 3/5] test: add attribute with spaces in equation label --- .../smoke-all/crossrefs/equations/equations-alt.qmd | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd b/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd index 4255def4ebb..54c8a68cf55 100644 --- a/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd +++ b/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd @@ -13,12 +13,14 @@ _quarto: - - "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']" @@ -29,6 +31,7 @@ _quarto: - - "\\\\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\\}" @@ -38,6 +41,7 @@ _quarto: - - "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", \\[ \\$ E = m c\\^2 \\$ \\]\\)" - "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"Einsteins mass-energy equivalence equation\", \\[ \\$ E = m c\\^2 \\$ \\]\\)" + - "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"Einsteins mass-energy equivalence equation\", \\[ \\$ E = m c\\^2 \\$ \\]\\)" - "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"Newton's second law of motion\", \\[ \\$ F = m a \\$ \\]\\)" - "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"The \\\\\"Pythagorean\\\\\" theorem\", \\[ \\$ a\\^2 \\+ b\\^2 = c\\^2 \\$ \\]\\)" - "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"This is using \\\\\"quotes\\\\\" but I'm sure it works\", \\[ \\$ x \\+ y = z \\$ \\]\\)" @@ -71,6 +75,12 @@ $$ {#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) $$ From 8aecde1c2a1419c76bcbbee44688cdb628ed7b8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:21:54 +0100 Subject: [PATCH 4/5] fix: normalise attributes string to ensure proper parsing by Pandoc --- src/resources/filters/crossref/equations.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/resources/filters/crossref/equations.lua b/src/resources/filters/crossref/equations.lua index 92efee030a0..40652314e17 100644 --- a/src/resources/filters/crossref/equations.lua +++ b/src/resources/filters/crossref/equations.lua @@ -239,6 +239,9 @@ function parseRefAttr(text) 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 From 242bf75b192a56cd06695e3e1e661b9f21bc06b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:22:21 +0100 Subject: [PATCH 5/5] test: add actual spaces around equal sign in alt attribute --- tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd b/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd index 54c8a68cf55..ec3a922f2b1 100644 --- a/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd +++ b/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd @@ -77,7 +77,7 @@ See @eq-display-alt. $$ E = mc^2 -$$ {#eq-display-alt-spaces alt="Einsteins mass-energy equivalence equation"} +$$ {#eq-display-alt-spaces alt = "Einsteins mass-energy equivalence equation"} See @eq-display-alt-spaces.