Skip to content

Commit 045d04a

Browse files
Fix: caption should not be used as fallback alt text
Remove the caption-as-alt fallback introduced in the PDF/UA compliance work (a867c3c, ba75b37). Using captions as alt text is an accessibility anti-pattern — captions describe a figure's significance in context while alt text describes what the image looks like. LaTeX: remove 3 caption-as-alt blocks in latex.lua, and add fig-alt to alt conversion in pandoc3_figure.lua for Pandoc 3 Figures without cross-ref labels. Typst: mark figure images with _quarto_no_caption_alt so that the caption-as-alt fallback in typst.lua only fires for inline images (where image.caption IS the standard markdown alt text). Key insight: In Pandoc 3, {alt="text"} replaces the Image's caption content rather than populating image.attributes["alt"]. So image.caption serves double duty as both visible caption and alt text override. We distinguish the two cases by comparing image.caption to figure.caption — when they match, the caption was NOT overridden (the bug case we suppress); when they differ, an explicit {alt="..."} was provided (which we preserve). This is the same heuristic Pandoc's own Markdown writer uses when round-tripping Figures. Explicit fig-alt (Quarto's dedicated attribute) flows through a completely separate path and always works unambiguously. Fixes #14107
1 parent b3d901f commit 045d04a

8 files changed

Lines changed: 105 additions & 15 deletions

File tree

src/resources/filters/customnodes/floatreftarget.lua

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,22 @@ end, function(float)
978978
local kind = "quarto-float-" .. ref
979979
local supplement = titleString(ref, info.name)
980980

981+
-- For figures: mark images so typst.lua won't use caption-as-alt fallback
982+
-- when caption IS the visible figure caption (not an explicit alt override).
983+
-- In Pandoc 3, {alt="text"} replaces image.caption with the alt value,
984+
-- so image.caption != float.caption means an explicit alt was provided.
985+
if ref == "fig" then
986+
local float_caption_text = pandoc.utils.stringify(float.caption_long or {})
987+
float.content = _quarto.ast.walk(float.content, {
988+
Image = function(img)
989+
if pandoc.utils.stringify(img.caption) == float_caption_text then
990+
img.attributes["_quarto_no_caption_alt"] = "true"
991+
end
992+
return img
993+
end
994+
})
995+
end
996+
981997
-- Inject show rule to left-align listing figures (only once per document)
982998
-- This overrides any template centering for listing-kind figures
983999
-- https://github.com/quarto-dev/quarto-cli/issues/9724

src/resources/filters/layout/latex.lua

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -332,10 +332,6 @@ function latexCell(cell, vAlign, endOfRow, endOfTable)
332332
-- see if it's a captioned figure
333333
if image and #image.caption > 0 then
334334
caption = image.caption:clone()
335-
-- preserve caption as alt attribute for PDF accessibility before clearing
336-
if not image.attributes["alt"] then
337-
image.attributes["alt"] = pandoc.utils.stringify(image.caption)
338-
end
339335
tclear(image.caption)
340336
elseif tbl then
341337
caption = pandoc.utils.blocks_to_inlines(tbl.caption.long)
@@ -384,10 +380,6 @@ function latexCell(cell, vAlign, endOfRow, endOfTable)
384380
if image and #image.caption > 0 then
385381
local caption = image.caption:clone()
386382
markupLatexCaption(cell, caption)
387-
-- preserve caption as alt attribute for PDF accessibility before clearing
388-
if not image.attributes["alt"] then
389-
image.attributes["alt"] = pandoc.utils.stringify(image.caption)
390-
end
391383
tclear(image.caption)
392384
content:insert(pandoc.RawBlock("latex", "\\raisebox{-\\height}{"))
393385
content:insert(pandoc.Para(image))
@@ -669,10 +661,6 @@ function latexImageFigure(image)
669661

670662
-- make a copy of the caption and clear it
671663
local caption = image.caption:clone()
672-
-- preserve caption as alt attribute for PDF accessibility before clearing
673-
if #image.caption > 0 and not image.attributes["alt"] then
674-
image.attributes["alt"] = pandoc.utils.stringify(image.caption)
675-
end
676664
tclear(image.caption)
677665

678666
-- get align

src/resources/filters/layout/pandoc3_figure.lua

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,13 @@ function render_pandoc3_figure()
146146
for k, v in pairs(figure.attributes) do
147147
image.attributes[k] = v
148148
end
149+
-- Convert fig-alt to alt for LaTeX \includegraphics[alt=...]
150+
if image.attributes[kFigAlt] then
151+
if not image.attributes["alt"] then
152+
image.attributes["alt"] = image.attributes[kFigAlt]
153+
end
154+
image.attributes[kFigAlt] = nil
155+
end
149156
if subfig then
150157
image.attributes['quarto-caption-env'] = 'subcaption'
151158
end
@@ -170,6 +177,26 @@ function render_pandoc3_figure()
170177
return {
171178
traverse = "topdown",
172179
Figure = function(figure)
180+
-- For figure images: prevent caption-as-alt fallback when caption IS the
181+
-- visible figure caption (not an explicit alt override via {alt="..."}).
182+
-- In Pandoc 3, {alt="text"} replaces image.caption with the alt value,
183+
-- so image.caption != figure.caption means an explicit alt was provided.
184+
-- Also propagate fig-alt from figure to image for accessibility.
185+
local figure_caption_text = pandoc.utils.stringify(figure.caption.long)
186+
local fig_alt = figure.attributes[kFigAlt]
187+
for _, block in ipairs(figure.content) do
188+
if block.t == "Plain" or block.t == "Para" then
189+
for _, inline in ipairs(block.content) do
190+
if inline.t == "Image" then
191+
if fig_alt then
192+
inline.attributes[kFigAlt] = fig_alt
193+
elseif pandoc.utils.stringify(inline.caption) == figure_caption_text then
194+
inline.attributes["_quarto_no_caption_alt"] = "true"
195+
end
196+
end
197+
end
198+
end
199+
end
173200
return make_typst_figure({
174201
content = figure.content[1],
175202
caption = figure.caption.long[1],

src/resources/filters/quarto-post/typst.lua

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,11 @@ function render_typst_fixups()
232232
if alt_text then
233233
image.attributes[kFigAlt] = nil
234234
end
235-
if (alt_text == nil or alt_text == "") and #image.caption > 0 then
235+
-- Use caption as alt only for inline images (not figures)
236+
-- Figure images are marked with _quarto_no_caption_alt by layout filters
237+
local no_caption_alt = image.attributes["_quarto_no_caption_alt"]
238+
image.attributes["_quarto_no_caption_alt"] = nil
239+
if (alt_text == nil or alt_text == "") and #image.caption > 0 and not no_caption_alt then
236240
alt_text = pandoc.utils.stringify(image.caption)
237241
end
238242

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
title: "Caption should not be used as fallback alt text"
3+
lang: en
4+
format:
5+
pdf:
6+
pdf-standard: ua-2
7+
keep-tex: true
8+
typst:
9+
pdf-standard: ua-1
10+
keep-typ: true
11+
_quarto:
12+
tests:
13+
run:
14+
# verapdf validation not available on Windows CI
15+
not_os: windows
16+
pdf:
17+
noErrors: default
18+
ensureLatexFileRegexMatches:
19+
- ['\\DocumentMetadata\{', 'pdfstandard=\{ua-2\}', 'tagging=on']
20+
- # Caption must NOT appear as alt text on \includegraphics
21+
['includegraphics\[.*alt=']
22+
typst:
23+
# Typst's own PDF/UA enforcement errors on missing alt
24+
shouldError: default
25+
---
26+
27+
# Caption is not alt text
28+
29+
A figure with a caption but no explicit `fig-alt`.
30+
The caption should NOT be copied into alt text.
31+
32+
![This is a caption, not alt text](penrose.svg)
Lines changed: 16 additions & 0 deletions
Loading

tests/docs/smoke-all/pdf-standard/typst-image-alt-text.qmd

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,18 @@ _quarto:
99
noErrors: default
1010
ensureTypstFileRegexMatches:
1111
- # Patterns that MUST be found - alt text in image() calls
12-
- 'image\("tc1-figure\.svg",\s*alt:\s*"TC1 figure caption as alt'
1312
- 'image\("tc2-inline\.svg",\s*alt:\s*"TC2 inline image'
1413
- 'image\("tc3-explicit\.svg",\s*alt:\s*"TC3 explicit alt attribute'
1514
- 'image\("tc4-dimensions\.svg",\s*alt:\s*"TC4 with dimensions",\s*height:\s*1in,\s*width:\s*1in'
1615
- 'image\("tc5-quotes\.svg",\s*alt:\s*"TC5 with \\"escaped\\" quotes'
1716
- 'image\("tc6-backslash\.svg",\s*alt:\s*"TC6 backslash C:\\\\path'
1817
# TC7 should have the image but without alt parameter
1918
- 'image\("tc7-no-alt\.svg"\)'
19+
# TC8: Explicit fig-alt should produce alt text
20+
- 'image\("tc8-fig-alt\.svg",\s*alt:\s*"TC8 explicit fig-alt'
2021
- # Patterns that must NOT be found
22+
# TC1 figure caption should NOT be used as alt text
23+
- 'tc1-figure\.svg.*alt:.*TC1 figure caption'
2124
# TC7 with no caption/alt should NOT have alt parameter
2225
- 'tc7-no-alt\.svg.*alt:'
2326
---
@@ -55,3 +58,7 @@ Here is ![TC4 with dimensions](tc4-dimensions.svg){width=1in height=1in} inline.
5558
This image has no caption and no alt attribute.
5659

5760
![](tc7-no-alt.svg)
61+
62+
## TC8: Explicit fig-alt on a figure
63+
64+
![TC8 visible caption](tc8-fig-alt.svg){fig-alt="TC8 explicit fig-alt description"}

tests/docs/smoke-all/pdf-standard/ua-image-alt-text.qmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ _quarto:
3232

3333
This image has alt text which should be passed through for PDF/UA compliance.
3434

35-
![Test image description](penrose.svg)
35+
![Test image description](penrose.svg){fig-alt="A Penrose tiling pattern"}

0 commit comments

Comments
 (0)