diff --git a/src/command/render/latexmk/parse-error.ts b/src/command/render/latexmk/parse-error.ts index 94459309674..09cb24949a3 100644 --- a/src/command/render/latexmk/parse-error.ts +++ b/src/command/render/latexmk/parse-error.ts @@ -125,8 +125,10 @@ export function findPdfAccessibilityWarnings( // Match: Package tagpdf Warning: Alternative text for graphic is missing. // (tagpdf) Using 'filename' instead. + // Note: tagpdf wraps long filenames across multiple (tagpdf) continuation + // lines, so we allow optional line breaks with (tagpdf) prefixes. const altTextRegex = - /Package tagpdf Warning: Alternative text for graphic is missing\.\s*\n\(tagpdf\)\s*Using ['`]([^'`]+)['`] instead\./g; + /Package tagpdf Warning: Alternative text for graphic is missing\.\s*\n\(tagpdf\)\s*Using ['`]([^'`]+)['`]\s*(?:\n\(tagpdf\)\s*)?instead\./g; let match; while ((match = altTextRegex.exec(logText)) !== null) { result.missingAltText.push(match[1]); diff --git a/src/command/render/latexmk/pdf.ts b/src/command/render/latexmk/pdf.ts index 9af74e170f7..2e7d5010de2 100644 --- a/src/command/render/latexmk/pdf.ts +++ b/src/command/render/latexmk/pdf.ts @@ -204,7 +204,7 @@ async function initialCompileLatex( if (accessibilityWarnings.missingAltText.length > 0) { const fileList = accessibilityWarnings.missingAltText.join(", "); warning( - `PDF accessibility: Missing alt text for image(s): ${fileList}. Add alt text using  syntax for PDF/UA compliance.\n`, + `PDF accessibility: Missing alt text for image(s): ${fileList}. Add alt text using fig-alt in YAML or {fig-alt="description"} on the image for PDF/UA compliance.\n`, ); } if (accessibilityWarnings.missingLanguage) { diff --git a/src/resources/filters/customnodes/floatreftarget.lua b/src/resources/filters/customnodes/floatreftarget.lua index 6d70e90db08..7b4f457a6cc 100644 --- a/src/resources/filters/customnodes/floatreftarget.lua +++ b/src/resources/filters/customnodes/floatreftarget.lua @@ -978,6 +978,22 @@ end, function(float) local kind = "quarto-float-" .. ref local supplement = titleString(ref, info.name) + -- For figures: mark images so typst.lua won't use caption-as-alt fallback + -- when caption IS the visible figure caption (not an explicit alt override). + -- In Pandoc 3, {alt="text"} replaces image.caption with the alt value, + -- so image.caption != float.caption means an explicit alt was provided. + if ref == "fig" then + local float_caption_text = pandoc.utils.stringify(float.caption_long or {}) + float.content = _quarto.ast.walk(float.content, { + Image = function(img) + if pandoc.utils.stringify(img.caption) == float_caption_text then + img.attributes["_quarto_no_caption_alt"] = "true" + end + return img + end + }) + end + -- Inject show rule to left-align listing figures (only once per document) -- This overrides any template centering for listing-kind figures -- https://github.com/quarto-dev/quarto-cli/issues/9724 diff --git a/src/resources/filters/layout/latex.lua b/src/resources/filters/layout/latex.lua index df5619e8c12..1728c48a4ba 100644 --- a/src/resources/filters/layout/latex.lua +++ b/src/resources/filters/layout/latex.lua @@ -332,10 +332,6 @@ function latexCell(cell, vAlign, endOfRow, endOfTable) -- see if it's a captioned figure if image and #image.caption > 0 then caption = image.caption:clone() - -- preserve caption as alt attribute for PDF accessibility before clearing - if not image.attributes["alt"] then - image.attributes["alt"] = pandoc.utils.stringify(image.caption) - end tclear(image.caption) elseif tbl then caption = pandoc.utils.blocks_to_inlines(tbl.caption.long) @@ -384,10 +380,6 @@ function latexCell(cell, vAlign, endOfRow, endOfTable) if image and #image.caption > 0 then local caption = image.caption:clone() markupLatexCaption(cell, caption) - -- preserve caption as alt attribute for PDF accessibility before clearing - if not image.attributes["alt"] then - image.attributes["alt"] = pandoc.utils.stringify(image.caption) - end tclear(image.caption) content:insert(pandoc.RawBlock("latex", "\\raisebox{-\\height}{")) content:insert(pandoc.Para(image)) @@ -669,10 +661,6 @@ function latexImageFigure(image) -- make a copy of the caption and clear it local caption = image.caption:clone() - -- preserve caption as alt attribute for PDF accessibility before clearing - if #image.caption > 0 and not image.attributes["alt"] then - image.attributes["alt"] = pandoc.utils.stringify(image.caption) - end tclear(image.caption) -- get align diff --git a/src/resources/filters/layout/pandoc3_figure.lua b/src/resources/filters/layout/pandoc3_figure.lua index 89bba648c6a..42b66e54f28 100644 --- a/src/resources/filters/layout/pandoc3_figure.lua +++ b/src/resources/filters/layout/pandoc3_figure.lua @@ -146,6 +146,13 @@ function render_pandoc3_figure() for k, v in pairs(figure.attributes) do image.attributes[k] = v end + -- Convert fig-alt to alt for LaTeX \includegraphics[alt=...] + if image.attributes[kFigAlt] then + if not image.attributes["alt"] then + image.attributes["alt"] = image.attributes[kFigAlt] + end + image.attributes[kFigAlt] = nil + end if subfig then image.attributes['quarto-caption-env'] = 'subcaption' end @@ -170,6 +177,26 @@ function render_pandoc3_figure() return { traverse = "topdown", Figure = function(figure) + -- For figure images: prevent caption-as-alt fallback when caption IS the + -- visible figure caption (not an explicit alt override via {alt="..."}). + -- In Pandoc 3, {alt="text"} replaces image.caption with the alt value, + -- so image.caption != figure.caption means an explicit alt was provided. + -- Also propagate fig-alt from figure to image for accessibility. + local figure_caption_text = pandoc.utils.stringify(figure.caption.long) + local fig_alt = figure.attributes[kFigAlt] + for _, block in ipairs(figure.content) do + if block.t == "Plain" or block.t == "Para" then + for _, inline in ipairs(block.content) do + if inline.t == "Image" then + if fig_alt then + inline.attributes[kFigAlt] = fig_alt + elseif pandoc.utils.stringify(inline.caption) == figure_caption_text then + inline.attributes["_quarto_no_caption_alt"] = "true" + end + end + end + end + end return make_typst_figure({ content = figure.content[1], caption = figure.caption.long[1], diff --git a/src/resources/filters/quarto-post/typst.lua b/src/resources/filters/quarto-post/typst.lua index f56ddbe65c7..10e822405e4 100644 --- a/src/resources/filters/quarto-post/typst.lua +++ b/src/resources/filters/quarto-post/typst.lua @@ -232,7 +232,11 @@ function render_typst_fixups() if alt_text then image.attributes[kFigAlt] = nil end - if (alt_text == nil or alt_text == "") and #image.caption > 0 then + -- Use caption as alt only for inline images (not figures) + -- Figure images are marked with _quarto_no_caption_alt by layout filters + local no_caption_alt = image.attributes["_quarto_no_caption_alt"] + image.attributes["_quarto_no_caption_alt"] = nil + if (alt_text == nil or alt_text == "") and #image.caption > 0 and not no_caption_alt then alt_text = pandoc.utils.stringify(image.caption) end diff --git a/tests/docs/smoke-all/pdf-standard/caption-not-alt-ua.qmd b/tests/docs/smoke-all/pdf-standard/caption-not-alt-ua.qmd new file mode 100644 index 00000000000..6853eb32a3d --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/caption-not-alt-ua.qmd @@ -0,0 +1,41 @@ +--- +title: "Caption should not be used as fallback alt text" +lang: en +format: + pdf: + pdf-standard: ua-2 + keep-tex: true + typst: + pdf-standard: ua-1 + keep-typ: true +_quarto: + tests: + run: + # verapdf validation not available on Windows CI + not_os: windows + pdf: + noErrors: default + ensureLatexFileRegexMatches: + - ['\\DocumentMetadata\{', 'pdfstandard=\{ua-2\}', 'tagging=on'] + - # Caption must NOT appear as alt text on \includegraphics + ['includegraphics\[.*alt='] + printsMessage: + # tagpdf warns about missing alt text and falls back to filename; + # Quarto surfaces this as a user-facing warning + level: WARN + regex: "PDF accessibility:.*Missing alt text" + typst: + # Typst's own PDF/UA enforcement errors on missing alt + shouldError: default +--- + +# Caption is not alt text + +A figure with a caption but no explicit `fig-alt`. +The caption should NOT be copied into alt text. + +Uses a cross-ref label to go through FloatRefTarget, which produces +valid UA-2 structure. See `ua2-unlabeled-figure-caption.qmd` for the +known structural issue with unlabeled captioned figures. + +{#fig-test} diff --git a/tests/docs/smoke-all/pdf-standard/tc8-fig-alt.svg b/tests/docs/smoke-all/pdf-standard/tc8-fig-alt.svg new file mode 100644 index 00000000000..30308f170f9 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/tc8-fig-alt.svg @@ -0,0 +1,16 @@ + diff --git a/tests/docs/smoke-all/pdf-standard/typst-image-alt-text.qmd b/tests/docs/smoke-all/pdf-standard/typst-image-alt-text.qmd index c9070ce0e82..d839c512d30 100644 --- a/tests/docs/smoke-all/pdf-standard/typst-image-alt-text.qmd +++ b/tests/docs/smoke-all/pdf-standard/typst-image-alt-text.qmd @@ -9,7 +9,6 @@ _quarto: noErrors: default ensureTypstFileRegexMatches: - # Patterns that MUST be found - alt text in image() calls - - 'image\("tc1-figure\.svg",\s*alt:\s*"TC1 figure caption as alt' - 'image\("tc2-inline\.svg",\s*alt:\s*"TC2 inline image' - 'image\("tc3-explicit\.svg",\s*alt:\s*"TC3 explicit alt attribute' - 'image\("tc4-dimensions\.svg",\s*alt:\s*"TC4 with dimensions",\s*height:\s*1in,\s*width:\s*1in' @@ -17,7 +16,11 @@ _quarto: - 'image\("tc6-backslash\.svg",\s*alt:\s*"TC6 backslash C:\\\\path' # TC7 should have the image but without alt parameter - 'image\("tc7-no-alt\.svg"\)' + # TC8: Explicit fig-alt should produce alt text + - 'image\("tc8-fig-alt\.svg",\s*alt:\s*"TC8 explicit fig-alt' - # Patterns that must NOT be found + # TC1 figure caption should NOT be used as alt text + - 'tc1-figure\.svg.*alt:.*TC1 figure caption' # TC7 with no caption/alt should NOT have alt parameter - 'tc7-no-alt\.svg.*alt:' --- @@ -55,3 +58,7 @@ Here is {width=1in height=1in} inline. This image has no caption and no alt attribute.  + +## TC8: Explicit fig-alt on a figure + +{fig-alt="TC8 explicit fig-alt description"} diff --git a/tests/docs/smoke-all/pdf-standard/ua-image-alt-text.qmd b/tests/docs/smoke-all/pdf-standard/ua-image-alt-text.qmd index 48751e98923..88d81f01d10 100644 --- a/tests/docs/smoke-all/pdf-standard/ua-image-alt-text.qmd +++ b/tests/docs/smoke-all/pdf-standard/ua-image-alt-text.qmd @@ -32,4 +32,4 @@ _quarto: This image has alt text which should be passed through for PDF/UA compliance. - +{fig-alt="A Penrose tiling pattern"} diff --git a/tests/docs/smoke-all/pdf-standard/ua2-unlabeled-figure-caption.qmd b/tests/docs/smoke-all/pdf-standard/ua2-unlabeled-figure-caption.qmd new file mode 100644 index 00000000000..78b23fb391c --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/ua2-unlabeled-figure-caption.qmd @@ -0,0 +1,47 @@ +--- +title: "UA-2: unlabeled figure caption produces invalid structure" +lang: en +format: + pdf: + pdf-standard: ua-2 + keep-tex: true +_quarto: + tests: + run: + # verapdf validation not available on Windows CI + not_os: windows + pdf: + noErrors: default + ensureLatexFileRegexMatches: + - ['\\DocumentMetadata\{', 'pdfstandard=\{ua-2\}', 'tagging=on'] + - [] + printsMessage: + # Known issue: unlabeled captioned figures go through pandoc3_figure.lua + # which produces a bare \begin{figure}[H] environment. LaTeX's tagpdf + # places