Easily load accessible PDF and PDF/UA documents and interact with them via screen reader directly in your browser, without server-side dependencies.
This demo allows you to:
Implementation steps
To add accessibility via a screen reader to a PDF or PDF/UA with WebViewer:
Step 1: Choose your preferred web stack
Step 2: Add the ES6 JavaScript sample code provided in this guide
Once you generate your license key, it will automatically be included in your sample code below.
Apryse collects some data regarding your usage of the SDK for product improvement.
The data that Apryse collects include:
For clarity, no other data is collected by the SDK and Apryse has no access to the contents of your documents.
If you wish to continue without data collection, contact us and we will email you a no-tracking trial key for you to get started.
1/* ES6 Compliant Syntax */
2/* GitHub Copilot v1.0, Claude 3.5 Sonnet, August 24, 2025 */
3/* File: index.js */
4
5import WebViewer from '@pdftron/webviewer';
6
7// Accessible Reading Order section
8//
9// Code to load accessible PDFs and PDF/UA documents and interact with them
10// via screen reader directly in your browser, without server-side
11// dependencies
12//
13
14const defaultDoc = 'https://apryse.s3.us-west-1.amazonaws.com/public/files/samples/PDFUA_AcademicAbstract.pdf';
15
16let isInAccessibleReadingOrderMode = false;
17let isAccessibleReadingOrderModeNoStructure = false;
18let currentPage = 1;
19let prevItem = null;
20let currentText = '';
21let isMuted = false;
22
23// Reset all variables when a new document is loaded
24const onDocumentLoaded = async () => {
25 // Reset the demo variables
26 isInAccessibleReadingOrderMode = false;
27 isAccessibleReadingOrderModeNoStructure = false;
28 currentPage = 1;
29 prevItem = null;
30 currentText = '';
31};
32
33// Set accessible reading order mode no structure state
34const onAccessibleReadingOrderModeNoStructure = () => {
35 isAccessibleReadingOrderModeNoStructure = true;
36 console.log('No structure detected in the document.');
37
38 // Update UI controls
39 updateUIControls();
40};
41
42const onAccessibleReadingOrderModeStarted = () => {
43 isInAccessibleReadingOrderMode = true;
44 console.log('Accessible reading order mode started.');
45
46 // Update UI controls
47 updateUIControls();
48};
49
50const onAccessibleReadingOrderModeEnded = () => {
51 isInAccessibleReadingOrderMode = false;
52 console.log('Accessible reading order mode ended.');
53
54 // Update UI controls
55 updateUIControls();
56};
57
58const customizeUI = async (instance) => {
59 const { documentViewer } = instance.Core;
60
61 // Set the default document to load
62 await instance.Core.documentViewer.loadDocument(defaultDoc, {
63 extension: 'pdf',
64 });
65
66 // Initialize the accessible reading order mode
67 const manager = documentViewer.getAccessibleReadingOrderManager();
68 await manager.startAccessibleReadingOrderMode();
69 isInAccessibleReadingOrderMode = manager.isInAccessibleReadingOrderMode();
70
71 // Add event listeners
72 documentViewer.addEventListener('documentLoaded', onDocumentLoaded);
73 manager.addEventListener('accessibleReadingOrderModeNoStructure', onAccessibleReadingOrderModeNoStructure);
74 manager.addEventListener('accessibleReadingOrderModeStarted', onAccessibleReadingOrderModeStarted);
75 manager.addEventListener('accessibleReadingOrderModeEnded', onAccessibleReadingOrderModeEnded);
76};
77
78const getFirstElementOnPage = (instance, page) => {
79 const allContentElements = Array.from(
80 instance.Core.documentViewer.getViewerElement()
81 .querySelectorAll(`[data-element^="a11y-reader-content-${page}_"]`)
82 );
83 return allContentElements.length > 0 ? allContentElements[0] : null;
84};
85
86const getAllContentElements = (instance, page) => {
87 return Array.from(
88 instance.Core.documentViewer.getViewerElement()
89 .querySelectorAll(`[data-element^="a11y-reader-content-${page}_"]`)
90 );
91};
92
93// Toggle mute
94const toggleMute = () => {
95 const speechSynth = window?.speechSynthesis;
96 if (isMuted) {
97 speechSynth?.resume();
98 } else {
99 speechSynth?.pause();
100 }
101 isMuted = !isMuted;
102};
103
104// Highlight the current text element
105const highlightCurrentElementAndChangePage = (instance, element) => {
106 if (!element) return;
107 element.style.border = '2px solid rgba(0, 0, 0, 0.6)';
108 element.style.borderRadius = '4px';
109 element.style.boxSizing = 'border-box';
110
111 const dataElement = element.getAttribute('data-element') || '';
112 let elementPage = -1;
113
114 if (dataElement && dataElement.startsWith('a11y-reader-content-')) {
115 // Parse the page number from a11y-reader-content-{page}-{block}
116 const parts = dataElement.split('-');
117 if (parts.length > 4) {
118 // Should have at least 4 parts to extract the page number
119 elementPage = parseInt(parts[3], 10);
120 }
121 }
122
123 if (elementPage > 0) {
124 const currPage = instance?.Core.documentViewer.getCurrentPage();
125
126 if (elementPage !== currPage) {
127 instance?.Core.documentViewer.setCurrentPage(elementPage);
128 }
129 }
130};
131
132// Process selected content
133const processSelectedContent = (instance, content) => {
134 if (!content) return;
135
136 highlightCurrentElementAndChangePage(instance, content);
137 const typeDescription = getElementTypeDescription(content);
138 prevItem = content;
139
140 const baseText = content.textContent || '';
141 const text = typeDescription + baseText;
142 currentText = text;
143
144 speechSynthesis.cancel(); // Cancel any ongoing speech synthesis
145 readText(text, isMuted);
146};
147
148// Clear highlight from the previous element
149const clearPreviousHighlight = () => {
150 if (prevItem) {
151 prevItem.style.border = 'none';
152 }
153};
154
155// Get the next element in the reading order
156const nextElement = (instance) => {
157 const currPage = currentPage;
158
159 clearPreviousHighlight();
160
161 let content = null;
162
163 if (prevItem) {
164 // Find siblings that have the a11y-reader-content prefix
165 const allContentElements = getAllContentElements(instance, currPage);
166 const currentIndex = allContentElements.findIndex((el) => el === prevItem);
167
168 // Get the next element if available
169 if (currentIndex !== -1 && currentIndex < allContentElements.length - 1) {
170 content = allContentElements[currentIndex + 1];
171 }
172 } else {
173 content = getFirstElementOnPage(instance, currPage);
174 }
175
176
177 if (!content) {
178 content = getFirstElementOnPage(instance, currPage + 1);
179
180 if (content) {
181 currentPage += 1;
182 } else {
183 // Reset to start if no more content
184 currentPage = 1;
185 content = getFirstElementOnPage(instance, 1);
186 }
187 }
188
189 if (content) {
190 processSelectedContent(instance, content);
191 }
192};
193
194// Get the previous element in the reading order
195const previousElement = (instance) => {
196 const currPage = currentPage;
197
198 clearPreviousHighlight();
199 let content = null;
200
201 if (prevItem) {
202 // Find siblings that have the a11y-reader-content prefix
203 const allContentElements = getAllContentElements(instance, currPage);
204 const currentIndex = allContentElements.findIndex((el) => el === prevItem);
205
206 // Get the previous element if available
207 if (currentIndex > 0) {
208 content = allContentElements[currentIndex - 1];
209 } else if (currPage > 1) {
210 // if we are at the first element of the page, try to get the last element of the previous page
211 const prevPageElements = getAllContentElements(instance, currPage - 1);
212
213 if (prevPageElements.length > 0) {
214 content = getFirstElementOnPage(instance, currPage - 1);
215 currentPage -= 1;
216 }
217 }
218 } else {
219 // No current item yet, get the first one
220 content = getFirstElementOnPage(instance, currPage);
221 }
222
223 // If no content found, reset to the first element of the first page
224 if (!content) {
225 currentPage = 1;
226 content = getFirstElementOnPage(instance, 1);
227 }
228
229 if (content) {
230 processSelectedContent(instance, content);
231 }
232};
233
234// Speech Synthesis section
235//
236// Code to read the text content of the document using the browser's
237// speech synthesis API
238//
239
240const voiceIndex = 0;
241const pitch = 1;
242const rate = 1;
243const volume = 1;
244
245const readText = (documentText, isMuted) => {
246 const speechSynth = window?.speechSynthesis;
247 const utterance = new window.SpeechSynthesisUtterance(documentText);
248 if (voiceIndex) {
249 utterance.voice = speechSynth?.getVoices()[voiceIndex];
250 }
251 utterance.pitch = pitch;
252 utterance.rate = rate;
253 utterance.volume = isMuted ? 0 : volume;
254
255 if (speechSynth?.speaking) {
256 speechSynth?.resume();
257 } else {
258 speechSynth?.speak(utterance);
259 }
260};
261
262const getElementTypeDescription = (element) => {
263 const role = element.getAttribute('role')?.toLowerCase() || '';
264
265 if (role === 'heading') {
266 const level = element.getAttribute('aria-level');
267 if (level) {
268 return `Heading level ${level}, `;
269 }
270 }
271
272 // Check for common semantic elements based on tag name or class
273 const tagName = element.tagName?.toLowerCase() || '';
274 if (tagName) {
275 if (tagName === 'h1') return 'Heading level 1, ';
276 if (tagName === 'h2') return 'Heading level 2, ';
277 if (tagName === 'h3') return 'Heading level 3, ';
278 if (tagName === 'h4') return 'Heading level 4, ';
279 if (tagName === 'h5') return 'Heading level 5, ';
280 if (tagName === 'h6') return 'Heading level 6, ';
281
282 if (tagName === 'img' || role === 'img') {
283 const alt = element.getAttribute('alt');
284 if (alt) return `Image, ${alt}, `;
285 return 'Image, ';
286 }
287
288 if (tagName === 'figure') return 'Figure, ';
289 if (tagName === 'table' || role === 'table') return 'Table, ';
290 if (tagName === 'ul' || role === 'list') return 'List, ';
291 if (tagName === 'ol') return 'Ordered list, ';
292 if (tagName === 'li' || role === 'listitem') return 'List item, ';
293 if (tagName === 'a' || role === 'link') return 'Link, ';
294 if (tagName === 'button' || role === 'button') return 'Button, ';
295 if (tagName === 'input' || role === 'textbox') return 'Input field, ';
296 }
297
298 return '';
299};
300
301
302// WebViewer section
303//
304// This code initializes the WebViewer with the basic settings
305// that are found in the default showcase WebViewer
306//
307
308const searchParams = new URLSearchParams(window.location.search);
309const history = window.history || window.parent.history || window.top.history;
310const licenseKey = 'YOUR_LICENSE_KEY';
311const element = document.getElementById('viewer');
312
313// Initialize WebViewer with the specified settings
314WebViewer({
315 path: '/lib',
316 fullAPI: true, // Required for Accessible Reading Order
317 licenseKey: licenseKey,
318 enableFilePicker: true,
319}, element).then((instance) => {
320 // Enable the measurement toolbar so it appears with all the other tools, and disable Cloudy rectangular tool
321 const cloudyTools = [
322 instance.Core.Tools.ToolNames.CLOUDY_RECTANGULAR_AREA_MEASUREMENT,
323 instance.Core.Tools.ToolNames.CLOUDY_RECTANGULAR_AREA_MEASUREMENT2,
324 instance.Core.Tools.ToolNames.CLOUDY_RECTANGULAR_AREA_MEASUREMENT3,
325 instance.Core.Tools.ToolNames.CLOUDY_RECTANGULAR_AREA_MEASUREMENT4,
326 ];
327 instance.UI.enableFeatures([instance.UI.Feature.Measurement, instance.UI.Feature.Initials]);
328 instance.UI.disableTools(cloudyTools);
329
330 // Set default toolbar group to Annotate
331 instance.UI.setToolbarGroup('toolbarGroup-Annotate');
332
333 // Set default tool on mobile devices to Pan.
334 // https://apryse.atlassian.net/browse/WVR-3134
335 if (isMobileDevice()) {
336 instance.UI.setToolMode(instance.Core.Tools.ToolNames.PAN);
337 }
338
339 instance.Core.documentViewer.addEventListener('documentUnloaded', () => {
340 if (searchParams.has('file')) {
341 searchParams.delete('file');
342 history.replaceState(null, '', '?' + searchParams.toString());
343 }
344 });
345
346 instance.Core.annotationManager.enableAnnotationNumbering();
347
348 instance.UI.NotesPanel.enableAttachmentPreview();
349
350 // Add the demo-specific functionality
351 customizeUI(instance).then(() => {
352 // Create UI controls after demo is initialized
353 createUIControls(instance);
354 });
355});
356
357// Function to check if the user is on a mobile device
358const isMobileDevice = () => {
359 return (
360 /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|ipad|iris|kindle|Android|Silk|lge |maemo|midp|mmp|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i.test(
361 window.navigator.userAgent
362 ) ||
363 /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw-(n|u)|c55\/|capi|ccwa|cdm-|cell|chtm|cldc|cmd-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc-s|devi|dica|dmob|do(c|p)o|ds(12|-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(-|_)|g1 u|g560|gene|gf-5|g-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd-(m|p|t)|hei-|hi(pt|ta)|hp( i|ip)|hs-c|ht(c(-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i-(20|go|ma)|i230|iac( |-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|-[a-w])|libw|lynx|m1-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|-([1-8]|c))|phil|pire|pl(ay|uc)|pn-2|po(ck|rt|se)|prox|psio|pt-g|qa-a|qc(07|12|21|32|60|-[2-7]|i-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h-|oo|p-)|sdk\/|se(c(-|0|1)|47|mc|nd|ri)|sgh-|shar|sie(-|m)|sk-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h-|v-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl-|tdg-|tel(i|m)|tim-|t-mo|to(pl|sh)|ts(70|m-|m3|m5)|tx-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas-|your|zeto|zte-/i.test(
364 window.navigator.userAgent.substring(0, 4)
365 )
366 );
367}
368
369// Cleanup function for when the demo is closed or page is unloaded
370const cleanup = (instance) => {
371 const { documentViewer } = instance.Core;
372 const manager = documentViewer.getAccessibleReadingOrderManager();
373
374 if (typeof instance !== 'undefined' && instance.UI) {
375
376 // Cleanup
377 manager.endAccessibleReadingOrderMode();
378 speechSynthesis.cancel();
379
380 // Remove event listeners
381 manager.removeEventListener('accessibleReadingOrderModeStarted', onAccessibleReadingOrderModeStarted);
382 manager.removeEventListener('accessibleReadingOrderModeEnded', onAccessibleReadingOrderModeEnded);
383 manager.removeEventListener('accessibleReadingOrderModeNoStructure', onAccessibleReadingOrderModeNoStructure);
384 documentViewer.removeEventListener('documentLoaded', onDocumentLoaded);
385
386 console.log('Cleaning up compare-files demo');
387 }
388};
389
390// Register cleanup for page unload
391window.addEventListener('beforeunload', () => cleanup());
392window.addEventListener('unload', () => cleanup());
393
394// UI section
395//
396// Helper code to add controls to the viewer holding the buttons
397// This code creates a container for the buttons, styles them, and adds them to the viewer
398//
399
400// Previous button
401const prevButton = (instance) => {
402 const button = document.createElement('button');
403 button.className = 'btn btn-prev';
404 button.textContent = 'Previous';
405 button.disabled = true; // Initially disabled
406 button.onclick = () => {
407 previousElement(instance);
408 updateUIControls();
409 };
410
411 return button;
412};
413
414// Next button
415const nextButton = (instance) => {
416 const button = document.createElement('button');
417 button.className = 'btn btn-next';
418 button.textContent = 'Next';
419 button.disabled = true; // Initially disabled
420 button.onclick = () => {
421 nextElement(instance);
422 updateUIControls();
423 };
424
425 return button;
426};
427
428// Audio button
429const audioButton = (instance) => {
430 const button = document.createElement('button');
431 button.className = 'btn btn-audio';
432 button.textContent = 'Mute';
433 button.disabled = true; // Initially disabled
434 button.onclick = () => {
435 toggleMute(instance);
436 if (isMuted) {
437 button.textContent = 'Unmute';
438 } else {
439 button.textContent = 'Mute';
440 }
441 };
442
443 return button;
444};
445
446// Untagged Document alert and Current page text
447const currentPageText = () => {
448 const wrapper = document.createElement('div');
449 wrapper.className = 'current-page-text-wrapper';
450
451 const untaggedDocumentAlert = document.createElement('div');
452 untaggedDocumentAlert.className = 'untagged-document-alert';
453
454 const untaggedDocumentAlertTitle = document.createElement('span');
455 untaggedDocumentAlertTitle.className = 'untagged-document-alert-title';
456 untaggedDocumentAlertTitle.textContent = 'UNTAGGED DOCUMENT';
457
458 const untaggedDocumentAlertText = document.createElement('span');
459 untaggedDocumentAlertText.className = 'untagged-document-alert-text';
460 untaggedDocumentAlertText.textContent = 'No accessible tags or structure detected. Please upload a tagged document for the best accessible experience.';
461
462 untaggedDocumentAlert.appendChild(untaggedDocumentAlertTitle);
463 untaggedDocumentAlert.appendChild(untaggedDocumentAlertText);
464
465 wrapper.appendChild(untaggedDocumentAlert);
466
467 if (isAccessibleReadingOrderModeNoStructure) {
468 untaggedDocumentAlert.classList.add('visible');
469 }
470
471 const currentPageText = document.createElement('span');
472 currentPageText.className = 'current-page-text';
473 currentPageText.textContent = `Current Page: ${currentPage}`;
474 wrapper.appendChild(currentPageText);
475
476 return wrapper;
477};
478
479// Text console
480const textConsole = () => {
481 const wrapper = document.createElement('div');
482 wrapper.className = 'text-wrapper';
483
484 const textArea = document.createElement('div');
485 textArea.className = 'text-area';
486
487 const textConsoleTitle = document.createElement('div');
488 textConsoleTitle.className = 'text-console-title';
489 textConsoleTitle.textContent = 'Text Console';
490
491 textArea.appendChild(textConsoleTitle);
492
493 const textContent = document.createElement('div');
494 textContent.className = 'text-content';
495 textContent.textContent = currentText ? currentText : 'No text is currently being read. Press "Next" to begin reading content.';
496
497 textArea.appendChild(textContent);
498
499 wrapper.appendChild(textArea);
500
501 return wrapper;
502};
503
504const createUIControls = (instance) => {
505 // Create a container for all controls (label, dropdown, and buttons)
506 const controlsContainer = document.createElement('div');
507 controlsContainer.className = 'controls-container';
508
509 // Container for Buttons and Page Text
510 const leftContainer = document.createElement('div');
511 leftContainer.className = 'left-container';
512
513 // Buttons Container
514 const buttonsContainer = document.createElement('div');
515 buttonsContainer.className = 'buttons-container';
516 buttonsContainer.appendChild(prevButton(instance));
517 buttonsContainer.appendChild(nextButton(instance));
518 buttonsContainer.appendChild(audioButton(instance));
519 leftContainer.appendChild(buttonsContainer);
520
521 // Current Page Text
522 leftContainer.appendChild(currentPageText());
523
524 controlsContainer.appendChild(leftContainer);
525
526 // Text Console
527 const textConsoleElement = textConsole();
528 controlsContainer.appendChild(textConsoleElement);
529
530 element.insertBefore(controlsContainer, element.firstChild);
531
532 updateUIControls();
533};
534
535const updateUIControls = () => {
536 // Update the state of the buttons based on the current mode
537 const nextButton = document.querySelector('.btn-next');
538 if (nextButton) {
539 nextButton.disabled = !isInAccessibleReadingOrderMode;
540 }
541
542 const prevButton = document.querySelector('.btn-prev');
543 if (prevButton) {
544 prevButton.disabled =
545 !isInAccessibleReadingOrderMode ||
546 !prevItem;
547 }
548
549 const audioButton = document.querySelector('.btn-audio');
550 if (audioButton) {
551 audioButton.disabled = !isInAccessibleReadingOrderMode;
552 }
553
554 // Show or hide the untagged document alert
555 const untaggedDocumentAlert = document.querySelector('.untagged-document-alert');
556 if (untaggedDocumentAlert) {
557 if (isAccessibleReadingOrderModeNoStructure) {
558 untaggedDocumentAlert.classList.add('visible');
559 } else {
560 untaggedDocumentAlert.classList.remove('visible');
561 }
562 }
563
564 // Update the current page text
565 const currentPageText = document.querySelector('.current-page-text');
566 if (currentPageText) {
567 currentPageText.textContent = `Current Page: ${currentPage}`;
568 }
569
570 // Update the text content in the text console
571 const textContent = document.querySelector('.text-content');
572 if (textContent) {
573 textContent.textContent = currentText ? currentText : 'No text is currently being read. Press "Next" to begin reading content.';
574 }
575}
Did you find this helpful?
Trial setup questions?
Ask experts on DiscordNeed other help?
Contact SupportPricing or product questions?
Contact Sales