Skip to content
Merged
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 @@ -27,6 +27,7 @@ All changes included in 1.11:
- ([#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.
- ([#15019](https://github.com/quarto-dev/quarto-cli/issues/15019)): Fix screen reader announcements in `revealjs` presentations including incremental content before it is revealed and repeating slides and fragments when browser history is enabled. (author: @jooyoungseo)
- ([#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
83 changes: 83 additions & 0 deletions package/src/common/patches/revealjs-0002-image-announcements.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
diff --git a/js/reveal.js b/js/reveal.js
index 52b19df..8e5f5ee 100644
--- a/js/reveal.js
+++ b/js/reveal.js
@@ -392,6 +392,11 @@ 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';
let isFragmentHidden = false;
@@ -419,9 +424,23 @@ export default function( revealElement, options ) {

if( isAriaHidden !== 'true' && !isDisplayHidden && !isFragmentHidden ) {

- Array.from( node.childNodes ).forEach( child => {
- text += getStatusText( child, rootNode );
- } );
+ // 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, rootNode );
+ } );
+ }

}

@@ -433,6 +452,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
20 changes: 13 additions & 7 deletions package/src/common/update-html-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,14 +305,20 @@ export async function updateHtmlDependencies(config: Configuration) {
"REVEAL_JS",
workingDir,
async (dir: string, version: string) => {
// Backport the unmerged PR hakimel/reveal.js#3946 for reveal.js 5.1.0.
// Before removing the source patch and rebuild step, check that the PR
// has landed in the reveal.js release being bundled.
// Backport the unmerged PRs hakimel/reveal.js#3946 and #3949, plus
// native image alt text from reveal.js 6, to 5.1.0. Before removing these
// source patches and rebuild step, verify all fixes are in the release
// we bundle. The image patch applies after the screen reader patch.
const sourceDir = join(dir, `reveal.js-${version}`);
await runCmd("git", [
"-C", sourceDir, "apply", "--ignore-space-change",
join(patchesDir, "revealjs-0001-screen-reader-announcements.patch"),
]);
for (const patch of [
"revealjs-0001-screen-reader-announcements.patch",
"revealjs-0002-image-announcements.patch",
]) {
await runCmd("git", [
"-C", sourceDir, "apply", "--ignore-space-change",
join(patchesDir, patch),
]);
}
// Skip install scripts: rebuilding the bundles does not need Puppeteer.
await runCmd(config.os === "windows" ? "npm.cmd" : "npm", [
"--prefix", sourceDir, "ci", "--ignore-scripts",
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.

90 changes: 90 additions & 0 deletions tests/docs/playwright/revealjs/image-announcements.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
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">
```

## Fragment images

```{=html}
<div class="fragment" data-fragment-index="0">
Parent image
<img class="fragment" data-fragment-index="1" src="quarto.png" alt="Nested logo">
<div class="fragment fade-out" data-fragment-index="2" role="img" aria-label="Initially visible rating">Ignored stars</div>
</div>
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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);
});
}
});

// Exercise both backports together: image names must follow fragment state,
// including when a nested fade-out fragment becomes visible again on rewind.
test('image names respect nested fragment visibility', async ({ page }) => {
await page.goto('./revealjs/image-announcements.html#/fragment-images');
await expect(page.locator('.reveal.ready')).toBeAttached();
await expect(page.locator('section.slide.present')).toHaveId('fragment-images');
const status = page.locator('.aria-status');
await expect(status).toHaveText('Fragment images');

await page.keyboard.press('Space');
await expect(status).toHaveText('Parent image Initially visible rating');
await page.keyboard.press('Space');
await expect(status).toHaveText('Nested logo');
await page.keyboard.press('Space');
await expect(status).toHaveText('Initially visible rating');

await page.keyboard.press('ArrowLeft');
await expect(status).toHaveText('Nested logo');
await page.keyboard.press('ArrowLeft');
await expect(status).toHaveText('Parent image Initially visible rating');
});
Loading