From c7b25eaa36565336a181cdd824bdd5eb172c3014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Tue, 6 Jan 2026 19:12:27 +0100 Subject: [PATCH 1/8] fix(website-meta.ts): change meta selector from "name" to "property" (#13847) * fix: change meta selector from "name" to "property" * chore: update changelog for version 1.9 * chore: update changelog for og:site_name meta tags clarification Clarified the description of the change regarding the `og:site_name` meta tags to ensure proper metadata markdown processing. * chore: Update changelog for version 1.9 * test: check if og:site_name in generated HTML correctly uses variable in site title * test - use ensureHtmlElements instead of regex to check for HTML elements in tests --------- Co-authored-by: Christophe Dervieux --- news/changelog-1.9.md | 1 + src/project/types/website/website-meta.ts | 2 +- .../docs/smoke-all/2026/01/06/13847/.gitignore | 2 ++ .../docs/smoke-all/2026/01/06/13847/_quarto.yml | 17 +++++++++++++++++ .../smoke-all/2026/01/06/13847/_variables.yml | 1 + tests/docs/smoke-all/2026/01/06/13847/index.qmd | 13 +++++++++++++ 6 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 tests/docs/smoke-all/2026/01/06/13847/.gitignore create mode 100644 tests/docs/smoke-all/2026/01/06/13847/_quarto.yml create mode 100644 tests/docs/smoke-all/2026/01/06/13847/_variables.yml create mode 100644 tests/docs/smoke-all/2026/01/06/13847/index.qmd diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 17971c59d6c..9a19a3ca453 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -62,6 +62,7 @@ All changes included in 1.9: - ([#13547](https://github.com/quarto-dev/quarto-cli/issues/13547))`cookie-content: { type: express }` is now the default. Previously it was `type: implied`. It now means this will block cookies until the user expressly agrees to allow them (or continue blocking them if the user doesn't agree). - ([#13570](https://github.com/quarto-dev/quarto-cli/pull/13570)): Replace Twitter with Bluesky in default blog template and documentation examples. New blog projects now include Bluesky social links instead of Twitter. - ([#13716](https://github.com/quarto-dev/quarto-cli/issues/13716)): Fix draft pages showing blank during preview when pre-render scripts are configured. +- ([#13847](https://github.com/quarto-dev/quarto-cli/pull/13847)): Open graph title with markdown is now processed correctly. (author: @mcanouil) ### `book` diff --git a/src/project/types/website/website-meta.ts b/src/project/types/website/website-meta.ts index 7f5afe68658..b37985436ef 100644 --- a/src/project/types/website/website-meta.ts +++ b/src/project/types/website/website-meta.ts @@ -506,7 +506,7 @@ function metaMarkdownPipeline(format: Format, extras: FormatExtras) { if (renderedEl) { // Update the document title const el = doc.querySelector( - `meta[name="og:site_name"]`, + `meta[property="og:site_name"]`, ); if (el) { el.setAttribute("content", renderedEl.innerText); diff --git a/tests/docs/smoke-all/2026/01/06/13847/.gitignore b/tests/docs/smoke-all/2026/01/06/13847/.gitignore new file mode 100644 index 00000000000..ad293093b07 --- /dev/null +++ b/tests/docs/smoke-all/2026/01/06/13847/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/2026/01/06/13847/_quarto.yml b/tests/docs/smoke-all/2026/01/06/13847/_quarto.yml new file mode 100644 index 00000000000..2df8ca97fcf --- /dev/null +++ b/tests/docs/smoke-all/2026/01/06/13847/_quarto.yml @@ -0,0 +1,17 @@ +project: + type: website + +website: + title: "Quarto CLI {{< var version >}}" + open-graph: true + navbar: + left: + - href: index.qmd + text: Home + right: + - icon: github + href: https://github.com/ + +format: + html: + theme: cosmo diff --git a/tests/docs/smoke-all/2026/01/06/13847/_variables.yml b/tests/docs/smoke-all/2026/01/06/13847/_variables.yml new file mode 100644 index 00000000000..2ef3d523ab5 --- /dev/null +++ b/tests/docs/smoke-all/2026/01/06/13847/_variables.yml @@ -0,0 +1 @@ +version: 1.0.0 diff --git a/tests/docs/smoke-all/2026/01/06/13847/index.qmd b/tests/docs/smoke-all/2026/01/06/13847/index.qmd new file mode 100644 index 00000000000..746cb5a7394 --- /dev/null +++ b/tests/docs/smoke-all/2026/01/06/13847/index.qmd @@ -0,0 +1,13 @@ +--- +title: "PR 13847: open graph metadata" +_quarto: + render-project: true + tests: + html: + ensureHtmlElements: + - + - 'meta[property="og:site_name"][content*="Quarto CLI 1.0.0"]' + - [] +--- + +This test check that the website title is correctly resolved when used in open graph site name, including when using a shortcode. \ No newline at end of file From c8941b109fd3d4a703c8803b13468149675a792f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:29:05 +0100 Subject: [PATCH 2/8] fix: Alt-text for math equations in Typst Fixes #13870 --- src/resources/filters/crossref/equations.lua | 223 +++++++++++++++---- src/resources/filters/crossref/refs.lua | 24 +- 2 files changed, 201 insertions(+), 46 deletions(-) diff --git a/src/resources/filters/crossref/equations.lua b/src/resources/filters/crossref/equations.lua index 00431e11a16..3428ead65df 100644 --- a/src/resources/filters/crossref/equations.lua +++ b/src/resources/filters/crossref/equations.lua @@ -5,7 +5,8 @@ function equations() return { Para = process_equations, - Plain = process_equations + Plain = process_equations, + Div = process_equation_div } end @@ -21,67 +22,86 @@ 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"] + elseif el.t == "Str" and startsWithEqLabel(el.text) 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 = attrText:match("{#(eq%-[^ }]+)") end - if _quarto.format.isHtmlOutput() and (mathMethod == "mathjax" or mathMethod == "katex") then - eqNumber = eqTag + + local order = indexNextOrder("eq") + indexAddEntry(label, nil, order) + + local eq = mathInlines[1] + + 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" + -- Alt attribute for Typst accessibility (ignored for other formats) + local alt_param = (attributes and attributes["alt"]) + and (", alt: \"" .. attributes["alt"] .. "\"") or "" + targetInlines:insert(pandoc.RawInline("typst", + "#math.equation(block: " .. is_block .. ", numbering: \"(1)\"" .. alt_param .. ", [ ")) + 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 + end + eq.text = eq.text .. " " .. eqNumber(inlinesToString(numberOption("eq", order))) + local span = pandoc.Span(eq, pandoc.Attr(label)) + targetInlines:insert(span) 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 + -- 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 @@ -117,3 +137,118 @@ end function isDisplayMath(el) return el.t == "Math" and el.mathtype == "DisplayMath" end + +-- Check if text starts with an equation label pattern {#eq- +function startsWithEqLabel(text) + return text and text:match("^{#eq%-") +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 collects and joins these elements into a single string +-- that can be parsed by parseRefAttr(). +-- +-- 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 + + -- Simple case: complete in one element (e.g., {#eq-label}) + if collected:match("}$") then + return collected, consumed + end + + -- Collect subsequent elements until closing brace + 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 + -- Pandoc parses quoted strings into Quoted elements + local quote = el.quotetype == "DoubleQuote" and '"' or "'" + collected = collected .. quote .. pandoc.utils.stringify(el.content) .. quote + consumed = consumed + 1 + else + break + end + if collected:match("}$") then + break + end + end + + -- Validate: must be a complete attribute block + if collected:match("^{#eq%-[^}]+}$") then + return collected, consumed + end + + return nil, 0 +end + +-- Process equation divs with optional alt-text attribute. +-- Supports syntax: ::: {#eq-label alt="description"} $$ ... $$ ::: +-- The alt attribute is only used for Typst output (accessibility). +function process_equation_div(divEl) + local label = divEl.attr.identifier + if not label or not label:match("^eq%-") then + return nil + end + + -- Find display math inside the div + local eq = nil + _quarto.ast.walk(divEl, { + Math = function(el) + if el.mathtype == "DisplayMath" then + eq = el + end + end + }) + + if not eq then + return nil + end + + local order = indexNextOrder("eq") + indexAddEntry(label, nil, order) + + -- Alt attribute for Typst accessibility (ignored for other formats) + local alt = divEl.attr.attributes["alt"] + + if _quarto.format.isLatexOutput() then + return pandoc.Para({ + pandoc.RawInline("latex", "\\begin{equation}"), + pandoc.Span(pandoc.RawInline("latex", eq.text), pandoc.Attr(label)), + pandoc.RawInline("latex", "\\end{equation}") + }) + elseif _quarto.format.isTypstOutput() then + local alt_param = alt and (", alt: \"" .. alt .. "\"") or "" + return pandoc.Para({ + pandoc.RawInline("typst", + "#math.equation(block: true, numbering: \"(1)\"" .. alt_param .. ", [ "), + eq, + 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))) + return pandoc.Para({pandoc.Span(eq, pandoc.Attr(label))}) + end +end diff --git a/src/resources/filters/crossref/refs.lua b/src/resources/filters/crossref/refs.lua index 39a82a22a7a..0448c6a56b5 100644 --- a/src/resources/filters/crossref/refs.lua +++ b/src/resources/filters/crossref/refs.lua @@ -188,7 +188,28 @@ function extractRefLabel(type, text) end function refLabelPattern(type) - return "{#(" .. type .. "%-[^ }]+)}" + -- Captures the identifier (type-name) while allowing optional attributes + return "{#(" .. type .. "%-[^ }]+)[^}]*}" +end + +-- Parse a Pandoc attribute block string into identifier and attributes. +-- Uses pandoc.read with a dummy header to leverage Pandoc's native parser. +-- +-- Input: "{#eq-label alt=\"description\"}" +-- Output: "eq-label", {alt = "description"} +-- +-- This is used to extract alt-text. +function parseRefAttr(text) + if not text then return nil, nil end + + -- Wrap in a markdown header since Pandoc requires text before attributes + -- to parse them correctly without regular expressions. + local parsed = pandoc.read("## x " .. 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 function is_valid_ref_type(type) @@ -210,4 +231,3 @@ function valid_ref_types() table.insert(types, "sec") return types end - From fa223731b864b34dbbbf8550422f8f123e0f399a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:33:43 +0100 Subject: [PATCH 3/8] refactor(equations.lua): reduce code duplication --- src/resources/filters/crossref/equations.lua | 108 ++++++++----------- 1 file changed, 46 insertions(+), 62 deletions(-) diff --git a/src/resources/filters/crossref/equations.lua b/src/resources/filters/crossref/equations.lua index 3428ead65df..71458b8940f 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() @@ -54,39 +54,9 @@ function process_equations(blockEl) indexAddEntry(label, nil, order) local eq = mathInlines[1] - - 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" - -- Alt attribute for Typst accessibility (ignored for other formats) - local alt_param = (attributes and attributes["alt"]) - and (", alt: \"" .. attributes["alt"] .. "\"") or "" - targetInlines:insert(pandoc.RawInline("typst", - "#math.equation(block: " .. is_block .. ", numbering: \"(1)\"" .. alt_param .. ", [ ")) - 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 - end - eq.text = eq.text .. " " .. eqNumber(inlinesToString(numberOption("eq", order))) - local span = pandoc.Span(eq, pandoc.Attr(label)) - targetInlines:insert(span) - end + 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 @@ -123,7 +93,46 @@ 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" + local alt_param = alt and (", alt: \"" .. alt .. "\"") or "" + 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) @@ -222,33 +231,8 @@ function process_equation_div(divEl) local order = indexNextOrder("eq") indexAddEntry(label, nil, order) - -- Alt attribute for Typst accessibility (ignored for other formats) local alt = divEl.attr.attributes["alt"] + local eqInlines = renderEquation(eq, label, alt, order) - if _quarto.format.isLatexOutput() then - return pandoc.Para({ - pandoc.RawInline("latex", "\\begin{equation}"), - pandoc.Span(pandoc.RawInline("latex", eq.text), pandoc.Attr(label)), - pandoc.RawInline("latex", "\\end{equation}") - }) - elseif _quarto.format.isTypstOutput() then - local alt_param = alt and (", alt: \"" .. alt .. "\"") or "" - return pandoc.Para({ - pandoc.RawInline("typst", - "#math.equation(block: true, numbering: \"(1)\"" .. alt_param .. ", [ "), - eq, - 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))) - return pandoc.Para({pandoc.Span(eq, pandoc.Attr(label))}) - end + return pandoc.Para(eqInlines) end From 21d8c3da9ee8ee07fa3bb1365c45597c51ebe073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 11 Jan 2026 13:16:23 +0100 Subject: [PATCH 4/8] chore: add entries for alt-text and div-syntax support --- news/changelog-1.9.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 9a19a3ca453..676236e6f20 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -40,6 +40,7 @@ All changes included in 1.9: - ([#13589](https://github.com/quarto-dev/quarto-cli/issues/13589)): Fix callouts with invalid ID prefixes crashing with "attempt to index a nil value". Callouts with unknown reference types now render as non-crossreferenceable callouts with a warning, ignoring the invalid ID. - ([#13602](https://github.com/quarto-dev/quarto-cli/issues/13602)): Fix support for multiple files set in `bibliography` field in `biblio.typ` template partial. - ([#13775](https://github.com/quarto-dev/quarto-cli/issues/13775)): Fix brand fonts not being applied when using `citeproc: true` with Typst format. Format detection now properly handles Pandoc format variants like `typst-citations`. +- ([#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` @@ -105,3 +106,4 @@ All changes included in 1.9: - ([#13575](https://github.com/quarto-dev/quarto-cli/pull/13575)): Improve CPU architecture detection/reporting in macOS to allow quarto to run in virtualized environments such as OpenAI's `codex`. - ([#13656](https://github.com/quarto-dev/quarto-cli/issues/13656)): Fix R code cells with empty `lang: ""` option producing invalid markdown class attributes. - ([#13832](https://github.com/quarto-dev/quarto-cli/pull/13832)): Fix `license.text` metadata not being accessible when using an inline license (`license: "text"`), and populate it with the license name for CC licenses instead of empty string. (author: @mcanouil) +- ([#13872](https://github.com/quarto-dev/quarto-cli/pull/13872)): Add support for div-syntax on cross-referenced equations. (author: @mcanouil) From c7bada2256e55c46204fe8d83d0eb218f4de5cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 11 Jan 2026 13:29:06 +0100 Subject: [PATCH 5/8] test: equation cross-references with alt-text and div syntax --- .../crossrefs/equations/equations-alt.qmd | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd 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..9d3fe54264a --- /dev/null +++ b/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd @@ -0,0 +1,72 @@ +--- +title: Equation Alt-Text and Div Syntax 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-div-alt > span.math" + - "a.quarto-xref[href='#eq-display-math']" + - "a.quarto-xref[href='#eq-display-alt']" + - "a.quarto-xref[href='#eq-div-alt']" + - [] + pdf: + ensureLatexFileRegexMatches: + - + - "\\\\begin\\{equation\\}\\\\phantomsection\\\\label\\{eq-display-math\\}" + - "\\\\begin\\{equation\\}\\\\phantomsection\\\\label\\{eq-display-alt\\}" + - "\\\\begin\\{equation\\}\\\\phantomsection\\\\label\\{eq-div-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 using classic syntax\", \\[ \\$ E = m c\\^2 \\$ \\]\\)" + - "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"Einsteins mass-energy equivalence equation using div syntax\", \\[ \\$ E = m c\\^2 \\$ \\]\\)" + - [] +--- + +## 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 using classic syntax"} + +See @eq-display-alt. + +## Div Syntax (with label and alt) + +::: {#eq-div-alt alt="Einsteins mass-energy equivalence equation using div syntax"} + +$$ +E = mc^2 +$$ + +::: + +See @eq-div-alt. From 60d59503aebc26bf650b8d549b87d51b62163552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 12 Jan 2026 21:25:15 +0100 Subject: [PATCH 6/8] refactor: focus on supporting alt attribute --- news/changelog-1.9.md | 2 +- src/resources/filters/crossref/equations.lua | 77 ++++++++++---------- src/resources/filters/crossref/refs.lua | 23 +----- 3 files changed, 41 insertions(+), 61 deletions(-) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 676236e6f20..a6846757e38 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -106,4 +106,4 @@ All changes included in 1.9: - ([#13575](https://github.com/quarto-dev/quarto-cli/pull/13575)): Improve CPU architecture detection/reporting in macOS to allow quarto to run in virtualized environments such as OpenAI's `codex`. - ([#13656](https://github.com/quarto-dev/quarto-cli/issues/13656)): Fix R code cells with empty `lang: ""` option producing invalid markdown class attributes. - ([#13832](https://github.com/quarto-dev/quarto-cli/pull/13832)): Fix `license.text` metadata not being accessible when using an inline license (`license: "text"`), and populate it with the license name for CC licenses instead of empty string. (author: @mcanouil) -- ([#13872](https://github.com/quarto-dev/quarto-cli/pull/13872)): Add support for div-syntax on cross-referenced equations. (author: @mcanouil) +- ([#13870](https://github.com/quarto-dev/quarto-cli/issues/13870)): Add `alt` attribute support for cross-referenced equations in Typst output for PDF UA-1 accessibility compliance. (author: @mcanouil) diff --git a/src/resources/filters/crossref/equations.lua b/src/resources/filters/crossref/equations.lua index 71458b8940f..43486a6dcfc 100644 --- a/src/resources/filters/crossref/equations.lua +++ b/src/resources/filters/crossref/equations.lua @@ -5,8 +5,7 @@ function equations() return { Para = process_equations, - Plain = process_equations, - Div = process_equation_div + Plain = process_equations } end @@ -39,7 +38,8 @@ function process_equations(blockEl) if el.t == "Space" then mathInlines:insert(el) processInline = false - elseif el.t == "Str" and startsWithEqLabel(el.text) then + -- 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) @@ -113,7 +113,12 @@ function renderEquation(eq, label, alt, order) elseif _quarto.format.isTypstOutput() then local is_block = eq.mathtype == "DisplayMath" and "true" or "false" - local alt_param = alt and (", alt: \"" .. alt .. "\"") or "" + -- Escape quotes in alt text for Typst string literal + local alt_param = "" + if alt then + local escaped_alt = 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) @@ -147,10 +152,6 @@ function isDisplayMath(el) return el.t == "Math" and el.mathtype == "DisplayMath" end --- Check if text starts with an equation label pattern {#eq- -function startsWithEqLabel(text) - return text and text:match("^{#eq%-") -end -- Collect a complete attribute block from inline elements. -- @@ -185,9 +186,16 @@ function collectAttrBlock(inlines, startIndex) collected = collected .. " " consumed = consumed + 1 elseif el.t == "Quoted" then - -- Pandoc parses quoted strings into Quoted elements + -- Pandoc parses quoted strings into Quoted elements. + -- Re-escape inner quotes that match the outer delimiter so parseRefAttr works. local quote = el.quotetype == "DoubleQuote" and '"' or "'" - collected = collected .. quote .. pandoc.utils.stringify(el.content) .. quote + 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 @@ -205,34 +213,27 @@ function collectAttrBlock(inlines, startIndex) return nil, 0 end --- Process equation divs with optional alt-text attribute. --- Supports syntax: ::: {#eq-label alt="description"} $$ ... $$ ::: --- The alt attribute is only used for Typst output (accessibility). -function process_equation_div(divEl) - local label = divEl.attr.identifier - if not label or not label:match("^eq%-") then - return nil - end - -- Find display math inside the div - local eq = nil - _quarto.ast.walk(divEl, { - Math = function(el) - if el.mathtype == "DisplayMath" then - eq = el - end - end - }) - - if not eq then - return nil +-- Parse a Pandoc attribute block string into identifier and attributes. +-- Uses pandoc.read with a dummy header to leverage Pandoc's native parser. +-- Similar technique is used in parseTableCaption() in common/tables.lua. +-- +-- Input: "{#eq-label alt=\"description\"}" +-- Output: "eq-label", {alt = "description"} +-- +-- This is used to extract alt-text for equations (Typst accessibility). +function parseRefAttr(text) + if not text then return nil, nil end + + -- Normalise curly/smart quotes to straight quotes (same as parseTableCaption). + -- This handles copy-pasted text from word processors. + text = text:gsub("“", "'"):gsub("”", "'") + + -- Wrap in a markdown header to parse them correctly without regular expressions. + 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 - - local order = indexNextOrder("eq") - indexAddEntry(label, nil, order) - - local alt = divEl.attr.attributes["alt"] - local eqInlines = renderEquation(eq, label, alt, order) - - return pandoc.Para(eqInlines) + return nil, nil end diff --git a/src/resources/filters/crossref/refs.lua b/src/resources/filters/crossref/refs.lua index 0448c6a56b5..fe87013b00c 100644 --- a/src/resources/filters/crossref/refs.lua +++ b/src/resources/filters/crossref/refs.lua @@ -188,28 +188,7 @@ function extractRefLabel(type, text) end function refLabelPattern(type) - -- Captures the identifier (type-name) while allowing optional attributes - return "{#(" .. type .. "%-[^ }]+)[^}]*}" -end - --- Parse a Pandoc attribute block string into identifier and attributes. --- Uses pandoc.read with a dummy header to leverage Pandoc's native parser. --- --- Input: "{#eq-label alt=\"description\"}" --- Output: "eq-label", {alt = "description"} --- --- This is used to extract alt-text. -function parseRefAttr(text) - if not text then return nil, nil end - - -- Wrap in a markdown header since Pandoc requires text before attributes - -- to parse them correctly without regular expressions. - local parsed = pandoc.read("## x " .. 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 + return "{#(" .. type .. "%-[^ }]+)}" end function is_valid_ref_type(type) From e61c79e0036a597b77e7e1d7d4a975229255e612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:14:36 +0100 Subject: [PATCH 7/8] fix: ensure single/double quotes work in "alt" pseudo-attribute --- src/resources/filters/crossref/equations.lua | 42 +++++++------ .../crossrefs/equations/equations-alt.qmd | 60 ++++++++++++++----- 2 files changed, 71 insertions(+), 31 deletions(-) diff --git a/src/resources/filters/crossref/equations.lua b/src/resources/filters/crossref/equations.lua index 43486a6dcfc..74aaf026da7 100644 --- a/src/resources/filters/crossref/equations.lua +++ b/src/resources/filters/crossref/equations.lua @@ -114,9 +114,12 @@ function renderEquation(eq, label, alt, order) 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('"', '\\"') + 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", @@ -158,8 +161,9 @@ end -- Pandoc tokenises `{#eq-label alt="description"}` into multiple elements: -- Str "{#eq-label", Space, Str "alt=", Quoted [...], Str "}" -- --- This function collects and joins these elements into a single string --- that can be parsed by parseRefAttr(). +-- 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) @@ -171,12 +175,10 @@ function collectAttrBlock(inlines, startIndex) local collected = first.text local consumed = 1 - -- Simple case: complete in one element (e.g., {#eq-label}) if collected:match("}$") then return collected, consumed end - -- Collect subsequent elements until closing brace for j = startIndex + 1, #inlines do local el = inlines[j] if el.t == "Str" then @@ -186,8 +188,6 @@ function collectAttrBlock(inlines, startIndex) collected = collected .. " " consumed = consumed + 1 elseif el.t == "Quoted" then - -- Pandoc parses quoted strings into Quoted elements. - -- Re-escape inner quotes that match the outer delimiter so parseRefAttr works. local quote = el.quotetype == "DoubleQuote" and '"' or "'" local content = pandoc.utils.stringify(el.content) if el.quotetype == "DoubleQuote" then @@ -205,7 +205,6 @@ function collectAttrBlock(inlines, startIndex) end end - -- Validate: must be a complete attribute block if collected:match("^{#eq%-[^}]+}$") then return collected, consumed end @@ -215,21 +214,30 @@ end -- Parse a Pandoc attribute block string into identifier and attributes. --- Uses pandoc.read with a dummy header to leverage Pandoc's native parser. --- Similar technique is used in parseTableCaption() in common/tables.lua. -- --- Input: "{#eq-label alt=\"description\"}" --- Output: "eq-label", {alt = "description"} +-- Uses pandoc.read() with a dummy header to leverage Pandoc's native attribute +-- parser, avoiding fragile regex-based parsing. -- --- This is used to extract alt-text for equations (Typst accessibility). +-- 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 - -- Normalise curly/smart quotes to straight quotes (same as parseTableCaption). - -- This handles copy-pasted text from word processors. - text = text:gsub("“", "'"):gsub("”", "'") + 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, "'") - -- Wrap in a markdown header to parse them correctly without regular expressions. local parsed = pandoc.read("## " .. text, "markdown") if parsed and parsed.blocks[1] and parsed.blocks[1].attr then local attr = parsed.blocks[1].attr diff --git a/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd b/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd index 9d3fe54264a..4255def4ebb 100644 --- a/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd +++ b/tests/docs/smoke-all/crossrefs/equations/equations-alt.qmd @@ -1,5 +1,5 @@ --- -title: Equation Alt-Text and Div Syntax Test +title: Equation Alt-Text Test format: html: default typst: @@ -13,23 +13,35 @@ _quarto: - - "span#eq-display-math > span.math" - "span#eq-display-alt > span.math" - - "span#eq-div-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-div-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\\}\\\\phantomsection\\\\label\\{eq-display-math\\}" - - "\\\\begin\\{equation\\}\\\\phantomsection\\\\label\\{eq-display-alt\\}" - - "\\\\begin\\{equation\\}\\\\phantomsection\\\\label\\{eq-div-alt\\}" + - "\\\\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 using classic syntax\", \\[ \\$ E = m c\\^2 \\$ \\]\\)" - - "#math\\.equation\\(block: true, numbering: \"\\(1\\)\", alt: \"Einsteins mass-energy equivalence equation using div syntax\", \\[ \\$ 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 \\$ \\]\\)" - [] --- @@ -55,18 +67,38 @@ See @eq-display-math. $$ E = mc^2 -$$ {#eq-display-alt alt="Einsteins mass-energy equivalence equation using classic syntax"} +$$ {#eq-display-alt alt="Einsteins mass-energy equivalence equation"} See @eq-display-alt. -## Div Syntax (with label and alt) +## Display Math (with single quote in alt) -::: {#eq-div-alt alt="Einsteins mass-energy equivalence equation using div syntax"} +$$ +F = ma +$$ {#eq-single-quote alt="Newton's second law of motion"} + +See @eq-single-quote. + +## Display Math (with double quotes in alt) $$ -E = mc^2 +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-div-alt. +See @eq-single-quote-alt. From 0f9c82421c505ae225492f6af0fac6121afd3cc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:21:25 +0100 Subject: [PATCH 8/8] refactor: replace label extraction with extractRefLabel() --- src/resources/filters/crossref/equations.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/filters/crossref/equations.lua b/src/resources/filters/crossref/equations.lua index 74aaf026da7..6f7e9b6ea08 100644 --- a/src/resources/filters/crossref/equations.lua +++ b/src/resources/filters/crossref/equations.lua @@ -47,7 +47,7 @@ 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 = attrText:match("{#(eq%-[^ }]+)") + label = extractRefLabel("eq", attrText) end local order = indexNextOrder("eq")