diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 17971c59d6c..a6846757e38 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` @@ -62,6 +63,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` @@ -104,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) +- ([#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/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/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/src/resources/filters/crossref/refs.lua b/src/resources/filters/crossref/refs.lua index 39a82a22a7a..fe87013b00c 100644 --- a/src/resources/filters/crossref/refs.lua +++ b/src/resources/filters/crossref/refs.lua @@ -210,4 +210,3 @@ function valid_ref_types() table.insert(types, "sec") return types end - 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 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.