Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ All changes included in 1.11:
- ([#15011](https://github.com/quarto-dev/quarto-cli/issues/15011)): Fix the slide menu of `revealjs` presentations being read by screen readers while it is closed and off screen, and Tab moving keyboard focus to it once it lists more slides than fit. (author: @jooyoungseo)
- ([#15015](https://github.com/quarto-dev/quarto-cli/issues/15015)): Fix the "Back to Article" link of notebook previews in website projects not being reachable or usable with the keyboard.
- ([#15030](https://github.com/quarto-dev/quarto-cli/pull/15030)): Fix `axe` checks in `revealjs` presentations hiding the current slide from screen readers when moving to another slide while the scan runs.
- ([#15033](https://github.com/quarto-dev/quarto-cli/issues/15033)): Fix screen reader announcements in `revealjs` presentations reading the CSS inside Mermaid diagrams, omitting image alternative text, and reading the contents of `role="img"` graphics instead of their accessible names. (author: @jooyoungseo)

## Formats

Expand Down
80 changes: 80 additions & 0 deletions package/src/common/patches/revealjs-0001-image-announcements.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
diff --git a/js/reveal.js b/js/reveal.js
index 927b0a1..45b18e9 100644
--- a/js/reveal.js
+++ b/js/reveal.js
@@ -392,13 +392,32 @@ export default function( revealElement, options ) {
// Element node
else if( node.nodeType === 1 ) {

+ // SVG style and script elements are not necessarily display: none.
+ if( node.localName === 'style' || node.localName === 'script' ) {
+ return '';
+ }
+
let isAriaHidden = node.getAttribute( 'aria-hidden' );
let isDisplayHidden = window.getComputedStyle( node )['display'] === 'none';
if( isAriaHidden !== 'true' && !isDisplayHidden ) {

- Array.from( node.childNodes ).forEach( child => {
- text += getStatusText( child );
- } );
+ // An image's contents are presentational; announce its name instead.
+ if( ( node.getAttribute( 'role' ) || '' ).trim().split( /\s+/ )[0] === 'img' ) {
+ text += getImageName( node );
+ }
+ else {
+ // Backport native image and video alt text support from reveal.js 6.
+ if( node.tagName === 'IMG' || node.tagName === 'VIDEO' ) {
+ let altText = node.getAttribute( 'alt' );
+ if( altText ) {
+ text += altText + ' ';
+ }
+ }
+
+ Array.from( node.childNodes ).forEach( child => {
+ text += getStatusText( child );
+ } );
+ }

}

@@ -410,6 +429,39 @@ export default function( revealElement, options ) {

}

+ /**
+ * Finds the accessible name of an element with role="img".
+ */
+ function getImageName( node ) {
+
+ let labelledBy = node.getAttribute( 'aria-labelledby' );
+ if( labelledBy ) {
+ let labels = labelledBy.trim().split( /\s+/ )
+ .map( id => node.ownerDocument.getElementById( id ) )
+ .filter( label => label );
+ if( labels.length ) {
+ return labels.map( label => label.textContent.trim() ).join( ' ' );
+ }
+ }
+
+ let label = node.getAttribute( 'aria-label' );
+ if( label && label.trim() ) {
+ return label;
+ }
+
+ if( node.localName === 'svg' ) {
+ let title = Array.from( node.children ).find( child => child.localName === 'title' );
+ return title ? title.textContent : '';
+ }
+
+ if( node.tagName === 'IMG' || node.tagName === 'VIDEO' ) {
+ return node.getAttribute( 'alt' ) || '';
+ }
+
+ return '';
+
+ }
+
/**
* This is an unfortunate necessity. Some actions – such as
* an input field being focused in an iframe or using the
19 changes: 18 additions & 1 deletion package/src/common/update-html-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,24 @@ export async function updateHtmlDependencies(config: Configuration) {
"hakimel/reveal.js",
"REVEAL_JS",
workingDir,
(dir: string, version: string) => {
async (dir: string, version: string) => {
// Backport image alt text from reveal.js 6 and the unmerged PR
// hakimel/reveal.js#3949 to 5.1.0. Before removing this source patch and
// rebuild step, verify both fixes are in the reveal.js release we bundle.
const sourceDir = join(dir, `reveal.js-${version}`);
await runCmd("git", [
"-C", sourceDir, "apply", "--ignore-space-change",
join(patchesDir, "revealjs-0001-image-announcements.patch"),
]);
// Rebuilding the bundles does not need Puppeteer's install scripts.
await runCmd(config.os === "windows" ? "npm.cmd" : "npm", [
"--prefix", sourceDir, "ci", "--ignore-scripts",
]);
await runCmd("node", [
join(sourceDir, "node_modules", "gulp", "bin", "gulp.js"),
"--cwd", sourceDir, "js",
]);

// Copy the desired resource files
info("Copying reveal.js resources' directory");
if (existsSync(revealJs)) {
Expand Down
2 changes: 1 addition & 1 deletion src/resources/formats/revealjs/reveal/dist/reveal.esm.js

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/resources/formats/revealjs/reveal/dist/reveal.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/resources/formats/revealjs/reveal/dist/reveal.js.map

Large diffs are not rendered by default.

80 changes: 80 additions & 0 deletions tests/docs/playwright/revealjs/image-announcements.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
title: "Image announcements"
format:
revealjs:
transition: none
---

## Mermaid

```{mermaid}
flowchart LR
A[Start] --> B[Middle] --> C[End]
```

## Image

![A caption]({{< placeholder 200 100 >}}){fig-alt="A grey rectangle"}

## Rating

```{=html}
<div role="img" aria-label="Rated 4 out of 5 stars">★★★★☆</div>
```

## Labelled chart

```{=html}
<span id="chart-name" hidden>Sales grew</span>
<span id="chart-period" hidden>every year</span>
<svg role="img" aria-labelledby="chart-name missing-label chart-period" aria-label="Ignored label" width="400" height="100">
<title>Ignored title</title>
<text x="20" y="50">2021 2022 2023 2024</text>
</svg>
```

## Titled chart

```{=html}
<svg role="img" width="400" height="100">
<title>Sales increased</title>
<text x="20" y="50">2021 2022</text>
</svg>
```

## Styled diagram

```{=html}
<svg width="400" height="100">
<style style="display: inline">.announcement-node { fill: black; }</style>
<script type="application/json" style="display: inline">{"notAnAnnouncement": true}</script>
<text class="announcement-node" x="20" y="50">Start</text>
<text class="announcement-node" x="200" y="50">End</text>
</svg>
```

## Decorative images

```{=html}
<img src="quarto.png" alt="">
<div role="img"><span>Unnamed image contents</span></div>
<div role="img" aria-hidden="true" aria-label="Hidden image">Hidden contents</div>
<div role="img" style="display: none" aria-label="Display-hidden image">Hidden contents</div>
```

## SVG titles

```{=html}
<svg width="400" height="100">
<title>Graph</title>
<g><title>Start to End</title><text x="20" y="50">Start</text><text x="200" y="50">End</text></g>
</svg>
```

## Label fallback

```{=html}
<div role="img" aria-labelledby="missing-name" aria-label="Fallback label">Ignored contents</div>
<div role="img" aria-labelledby="" aria-label="Empty-reference fallback">Ignored contents</div>
<img role="img" src="quarto.png" alt="Quarto logo">
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';

test.describe('revealjs image announcements (#15033)', () => {
const cases = [
{ slide: 'mermaid', expected: 'Mermaid Start Middle End' },
{ slide: 'image', expected: 'Image A grey rectangle A caption' },
{ slide: 'rating', expected: 'Rating Rated 4 out of 5 stars' },
{ slide: 'labelled-chart', expected: 'Labelled chart Sales grew every year' },
{ slide: 'titled-chart', expected: 'Titled chart Sales increased' },
{ slide: 'styled-diagram', expected: 'Styled diagram Start End' },
{ slide: 'decorative-images', expected: 'Decorative images' },
{ slide: 'svg-titles', expected: 'SVG titles Graph Start to End Start End' },
{ slide: 'label-fallback', expected: 'Label fallback Fallback label Empty-reference fallback Quarto logo' },
];

for (const { slide, expected } of cases) {
test(slide, async ({ page, browserName }) => {
await page.goto('./revealjs/image-announcements.html');
await expect(page.locator('.reveal.ready')).toBeAttached();
// Mermaid is rendered asynchronously. Enter its slide only after the SVG
// exists, so the test measures the diagram rather than its loading state.
await expect(page.locator('#mermaid svg')).toBeAttached();
await page.evaluate((id) => {
const reveal = (window as any).Reveal;
const indices = reveal.getIndices(document.getElementById(id));
reveal.slide(indices.h, indices.v);
}, slide);
await expect(page.locator('section.slide.present')).toHaveId(slide);
// WebKit hides SVG title elements by default; keep the existing
// computed-display filtering rather than changing that browser's output.
const expectedText = browserName === 'webkit' && slide === 'svg-titles'
? 'SVG titles Start End'
: expected;
await expect(page.locator('.aria-status')).toHaveText(expectedText);
});
}
});
Loading