Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for WCAG 2.1 SC 2.2.2: Handling of animated GIFs #60

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
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
27 changes: 27 additions & 0 deletions image.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,30 @@
right: 0;
transform: none;
}
.h5p-image .h5p-image-button-play {
background-color: rgb(23,104,196);
border: none;
box-sizing: border-box;
color: #fff;
font-family: 'H5PFontAwesome4';
height: 3em;
margin: 0;
padding: 0;
position: absolute;
right: 0.5em;
top: 0.5em;
width: 3em;
}
.h5p-image .h5p-image-button-play:hover {
background-color: rgba(23,104,196,0.9);
cursor: pointer;
}
.h5p-image .h5p-image-button-play:active {
background-color: rgba(23,104,196,0.95);
}
.h5p-image .h5p-image-button-play::before {
content: "\f04b";
}
.h5p-image .h5p-image-button-play.pause::before {
content: "\f04c";
}
202 changes: 202 additions & 0 deletions image.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ var H5P = H5P || {};
H5P.Image = function (params, id, extras) {
H5P.EventDispatcher.call(this);
this.extras = extras;
this.params = params;
this.params.startImageAnimation = 'Start image animation';
this.params.stopImageAnimation = 'Stop image animation';

if (params.file === undefined || !(params.file instanceof Object)) {
this.placeholder = true;
Expand All @@ -19,6 +22,7 @@ var H5P = H5P || {};
this.source = H5P.getPath(params.file.path, id);
this.width = params.file.width;
this.height = params.file.height;
this.mime = params.file.mime || '';
}

this.alt = (!params.decorative && params.alt !== undefined) ?
Expand All @@ -42,6 +46,7 @@ var H5P = H5P || {};
H5P.Image.prototype.attach = function ($wrapper) {
var self = this;
var source = this.source;
self.$wrapper = $wrapper;

if (self.$img === undefined) {
if(self.placeholder) {
Expand Down Expand Up @@ -73,8 +78,205 @@ var H5P = H5P || {};
}

$wrapper.addClass('h5p-image').html(self.$img);

/*
* Only handle if image is gif, animation is essential and user requested
* reduced motion
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
*/
if (
this.mime === 'image/gif' &&
!this.params.isAnimationEssential &&
window.matchMedia('(prefers-reduced-motion: reduce)')?.matches
) {
self.on('loaded', function () {
const image = self.$img.get(0);

// Promise resolving with true if src is animated gif
self.isGIFAnimated(image.src).then(function (isAnimated) {
if (!isAnimated) {
return; // GIF is not animated
}

self.staticImage = self.buildStaticReplacement(image);

// Add button to toggle gif animation on/off
self.playButton = document.createElement('button');
self.playButton.classList.add('h5p-image-button-play');
self.playButton.setAttribute('type', 'button');
self.playButton.addEventListener('click', function () {
self.swapImage();
});
$wrapper.get(0).append(self.playButton);

self.showsStaticImage = true;
self.swapImage();
}, function () {
return; // Error
});
});
}
};

/**
* Swap image. Used to switch between animated image and static image.
*/
H5P.Image.prototype.swapImage = function() {
this.showsStaticImage = !this.showsStaticImage;

this.playButton.classList.toggle('pause', this.showsStaticImage);
if (this.showsStaticImage) {
this.$wrapper.get(0).replaceChild(this.$img.get(0), this.staticImage);
this.playButton.setAttribute(
'aria-label', this.params.stopImageAnimation
);
}
else {
this.$wrapper.get(0).replaceChild(this.staticImage, this.$img.get(0));
this.playButton.setAttribute(
'aria-label', this.params.startImageAnimation
);
}

this.trigger('resize');
};

/**
* Build static replacement of image.
*
* @param {HTMLImageElement} image Image to get static replacement for.
* @returns {HTMLImageElement|HTMLCanvasElement} Static replacement.
*/
H5P.Image.prototype.buildStaticReplacement = function (image) {
if (!image) {
return;
}

// Image size is likely float, but canvas size cannot.
const style = window.getComputedStyle(image);
const imageSize = {
height: Math.round(parseFloat(style.getPropertyValue('height'))),
width: Math.round(parseFloat(style.getPropertyValue('width')))
}

// Copy image to canvas
const canvas = document.createElement('canvas');
canvas.width = imageSize.width;
canvas.height = imageSize.height;
canvas
.getContext('2d')
.drawImage(image, 0, 0, imageSize.width, imageSize.height);

// Try to retrieve encoded image URL
let newSrc;
let replacement;
try {
newSrc = canvas.toDataURL('image/gif');
replacement = document.createElement('img');
}
catch (error) {
// Fallback. e.g. cross-origin issues
replacement = canvas;
}

/*
* toDataURL requires images on iOS to have a maximum size of 3 megapixels
* for devices with less than 256 MB RAM and 5 megapixels for devices with
* greater or equal than 256 MB RAM.
* https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/CreatingContentforSafarioniPhone/CreatingContentforSafarioniPhone.html#//apple_ref/doc/uid/TP40006482-SW15
*/
if (newSrc.length < 7) {
replacement = canvas;
}

// Copy attributes of old source
for (let i = 0; i < image.attributes.length; i++) {
const attribute = image.attributes[i];
replacement.setAttribute(attribute.name, attribute.value);
}

if (newSrc) {
replacement.src = newSrc;
}
else {
// Common practice to make canvas behave like image to screen readers
replacement.setAttribute('role', 'img');
if (image.getAttribute('alt')) {
replacement.setAttribute('aria-label', image.getAttribute('alt'));
}
}

return replacement;
};

/**
* Determine whether a GIF file is animated.
*
* @param {string} url URL of GIF to be checked.
* @returns {Promise} Promise resolving with boolean, rejecting with xhr object extract.
*/
H5P.Image.prototype.isGIFAnimated = function (url) {
return new Promise(function (resolve, reject) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'arraybuffer';

// Reject Promise if file could not be loaded
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status !== 200) {
reject({ status: xhr.status, statusText: xhr.statusText });
}
}

/*
* Determine GIF delay time as indicator for animation
* @see https://gist.github.com/zakirt/faa4a58cec5a7505b10e3686a226f285
*/
xhr.onload = function() {
const buffer = xhr.response;

// Offset bytes for the header section
const HEADER_LEN = 6;

// Offset bytes for logical screen description section
const LOGICAL_SCREEN_DESC_LEN = 7;

// Start from last 4 bytes of Logical Screen Descriptor
const dv = new DataView(buffer, HEADER_LEN + LOGICAL_SCREEN_DESC_LEN - 3);
const globalColorTable = dv.getUint8(0); // aka packet byte
let globalColorTableSize = 0;
let offset = 0;

// Check first bit, if 0, then we don't have Global Color Table
if (globalColorTable & 0x80) {
/*
* Grab last 3 bits to compute global color table
* size -> RGB * 2^(N+1). N is value in last 3 bits.
*/
globalColorTableSize = 3 * Math.pow(2, (globalColorTable & 0x7) + 1);
}

// Move on to Graphics Control Extension
offset = 3 + globalColorTableSize;

const extensionIntroducer = dv.getUint8(offset);
const graphicsConrolLabel = dv.getUint8(offset + 1);
let delayTime = 0;

// Graphics Control Extension section is where GIF animation data is
// First 2 bytes must be 0x21 and 0xF9
if (extensionIntroducer & 0x21 && graphicsConrolLabel & 0xf9) {
// Skip to 2 bytes with delay time
delayTime = dv.getUint16(offset + 4);
}

resolve(Boolean(delayTime));
};

xhr.send();
});
}

/**
* Retrieve decoded HTML encoded string.
*
Expand Down
12 changes: 12 additions & 0 deletions language/.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
"label": "Decorative only",
"description": "Enable this option if the image is purely decorative and does not add any information to the content on the page. It will be ignored by screen readers and not given any alternative text."
},
{
"label": "Animation is essential",
"description": "Enable this option if the image is animated and playing this animation is essential. Otherwise, the user may have requested reduced motion and needs to actively start the animation."
},
{
"label": "Alternative text",
"description": "Required. If the browser can't load the image this text will be displayed instead. Also used by \"text-to-speech\" readers."
Expand All @@ -18,6 +22,14 @@
{
"label": "Image content name",
"default": "Image"
},
{
"label": "Start image animation",
"default": "Start image animation"
},
{
"label": "Stop image animation",
"default": "Stop image animation"
}
]
}
12 changes: 12 additions & 0 deletions language/af.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
"label": "Decorative only",
"description": "Enable this option if the image is purely decorative and does not add any information to the content on the page. It will be ignored by screen readers and not given any alternative text."
},
{
"label": "Animation is essential",
"description": "Enable this option if the image is animated and playing this animation is essential. Otherwise, the user may have requested reduced motion and needs to actively start the animation."
},
{
"label": "Alternatiewe teks",
"description": "Vereis. Indien die webleser nie die prent kan laai nie, sal die teks vertoon word. Word ook gebruik vir \"teks-na-spraak\" lesers."
Expand All @@ -18,6 +22,14 @@
{
"label": "Prent inhoud naam",
"default": "Prent"
},
{
"label": "Start image animation",
"default": "Start image animation"
},
{
"label": "Stop image animation",
"default": "Stop image animation"
}
]
}
12 changes: 12 additions & 0 deletions language/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
"label": "Decorative only",
"description": "Enable this option if the image is purely decorative and does not add any information to the content on the page. It will be ignored by screen readers and not given any alternative text."
},
{
"label": "Animation is essential",
"description": "Enable this option if the image is animated and playing this animation is essential. Otherwise, the user may have requested reduced motion and needs to actively start the animation."
},
{
"label": "النص البديل",
"description": "مطلوبة. إذا كان المتصفح لم يتمكن من تحميل الصورة سيتم عرض هذا النص بدلا من ذلك. تستخدم أيضا من قبل مكبرات الصوت للقراءة"
Expand All @@ -18,6 +22,14 @@
{
"label": "اسم ملف الصورة",
"default": "الصورة"
},
{
"label": "Start image animation",
"default": "Start image animation"
},
{
"label": "Stop image animation",
"default": "Stop image animation"
}
]
}
12 changes: 12 additions & 0 deletions language/bg.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
"label": "Decorative only",
"description": "Enable this option if the image is purely decorative and does not add any information to the content on the page. It will be ignored by screen readers and not given any alternative text."
},
{
"label": "Animation is essential",
"description": "Enable this option if the image is animated and playing this animation is essential. Otherwise, the user may have requested reduced motion and needs to actively start the animation."
},
{
"label": "Алтернативен текст",
"description": "Задължителен. Ако браузърът не може да зареди изображението, вместо него ще се покаже този текст. Използва се също и от екранните четци \"text-to-speech\"."
Expand All @@ -18,6 +22,14 @@
{
"label": "Име на тип съдържание Изображение",
"default": "Изображение"
},
{
"label": "Start image animation",
"default": "Start image animation"
},
{
"label": "Stop image animation",
"default": "Stop image animation"
}
]
}
12 changes: 12 additions & 0 deletions language/bs.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
"label": "Decorative only",
"description": "Enable this option if the image is purely decorative and does not add any information to the content on the page. It will be ignored by screen readers and not given any alternative text."
},
{
"label": "Animation is essential",
"description": "Enable this option if the image is animated and playing this animation is essential. Otherwise, the user may have requested reduced motion and needs to actively start the animation."
},
{
"label": "Alternativni tekst",
"description": "Obavezno. U slučaju da se slika ne pokaže onda će se pokazati ovaj tekst ili biti elektronskm glasom pročitan."
Expand All @@ -18,6 +22,14 @@
{
"label": "Naziv slike",
"default": "Image"
},
{
"label": "Start image animation",
"default": "Start image animation"
},
{
"label": "Stop image animation",
"default": "Stop image animation"
}
]
}
Loading