@@ -42,6 +42,148 @@ Use Microsoft Edge’s built-in Read Aloud to listen to the PDF content.
4242{% tabs %}
4343{% highlight js tabtitle="accessibility.js" %}
4444
45+ const synth = window.speechSynthesis;
46+
47+ let voices = [ ] ;
48+
49+ const voicesReady = new Promise((resolve) => {
50+ const tryGet = () => {
51+ const voice = synth.getVoices();
52+ if (voice && voice.length) {
53+ voices = voice;
54+ resolve(voice);
55+ return true;
56+ }
57+ return false;
58+ };
59+ if (tryGet()) return;
60+ const onVoices = () => {
61+ if (tryGet()) {
62+ synth.removeEventListener('voiceschanged', onVoices);
63+ }
64+ };
65+ synth.addEventListener('voiceschanged', onVoices);
66+ // Fallback polling for browsers that don't fire voiceschanged reliably
67+ let tries = 0;
68+ const poll = setInterval(() => {
69+ if (tryGet() || ++tries > 30) {
70+ clearInterval(poll);
71+ voices = synth.getVoices() || [ ] ;
72+ synth.removeEventListener('voiceschanged', onVoices);
73+ resolve(voices);
74+ }
75+ }, 100);
76+ });
77+
78+ // iOS/iPadOS Safari requires a user gesture before speech works reliably.
79+ // This one-time unlock speaks a silent utterance on first tap/click/keydown.
80+ let __ ttsUnlocked = false;
81+ let __ unlockPromise = null;
82+ function ensureTtsUnlocked() {
83+ if (__ ttsUnlocked) return Promise.resolve();
84+ if (__ unlockPromise) return __ unlockPromise;
85+ __ unlockPromise = new Promise((resolve) => {
86+ const cleanup = () => {
87+ [ 'click', 'touchstart', 'keydown'] .forEach(evt => document.removeEventListener(evt, onEvent, true));
88+ };
89+ const onEvent = () => {
90+ try {
91+ const u = new SpeechSynthesisUtterance(''); // silent token
92+ u.volume = 0;
93+ u.rate = 1;
94+ u.onend = () => {
95+ __ ttsUnlocked = true;
96+ cleanup();
97+ resolve();
98+ };
99+ // Queue in a macrotask to avoid race with gesture handling
100+ setTimeout(() => synth.speak(u), 0);
101+ } catch (_ ) {
102+ __ ttsUnlocked = true;
103+ cleanup();
104+ resolve();
105+ }
106+ };
107+ [ 'click', 'touchstart', 'keydown'] .forEach(evt => document.addEventListener(evt, onEvent, true));
108+ });
109+ return __ unlockPromise;
110+ }
111+
112+ function populateVoiceList() {
113+ voices = synth.getVoices().sort(function (a, b) {
114+ const aname = a.name.toUpperCase();
115+ const bname = b.name.toUpperCase();
116+
117+ if (aname < bname) {
118+ return -1;
119+ } else if (aname == bname) {
120+ return 0;
121+ } else {
122+ return +1;
123+ }
124+ });
125+ }
126+
127+ async function speakFromControls(input) {
128+ await voicesReady;
129+
130+ const t = (typeof input === 'string' ? input.trim() : (input?.value || '').trim());
131+ if (!t) return;
132+
133+ // Cancel any current speech to avoid overlaps/refresh quirks
134+ if (synth.speaking) {
135+ synth.cancel();
136+ }
137+
138+ const utterThis = new SpeechSynthesisUtterance(t);
139+
140+ utterThis.onend = function () {
141+ console.log("SpeechSynthesisUtterance.onend");
142+ };
143+
144+ utterThis.onerror = function (e) {
145+ console.error("SpeechSynthesisUtterance.onerror", e);
146+ };
147+
148+ const available = speechSynthesis.getVoices();
149+ let voice = null;
150+ voice = available.find(v => v.default) || available[0];
151+ if (voice) {
152+ utterThis.voice = voice;
153+ if (voice.lang) utterThis.lang = voice.lang; // Safari iOS respects lang better
154+ }
155+
156+ utterThis.pitch = 1;
157+ utterThis.rate = 1;
158+
159+ // Safari sometimes needs a microtask delay after cancel before speak
160+ setTimeout(() => synth.speak(utterThis), 0);
161+ }
162+
163+ async function initUi() {
164+ await voicesReady;
165+ // Populate voices now that controls exist
166+ populateVoiceList();
167+ if (speechSynthesis.onvoiceschanged !== undefined) {
168+ speechSynthesis.onvoiceschanged = populateVoiceList;
169+ }
170+ return true;
171+ }
172+
173+ // Initialize when DOM is ready; also handle Blazor re-renders
174+ document.addEventListener('DOMContentLoaded', async () => {
175+ // Set up iOS unlock listeners early
176+ ensureTtsUnlocked();
177+ if (await initUi()) return;
178+ const obs = new MutationObserver(async () => {
179+ if (await initUi()) obs.disconnect();
180+ });
181+ obs.observe(document.body, { childList: true, subtree: true });
182+ });
183+
184+ // Expose a helper to manually unlock from .NET or UI (tap/click)
185+ window.unlockTtsForIOS = () => ensureTtsUnlocked();
186+
45187// Initialize PDF accessibility features and observe page changes
46188function initPdfAccessibility() {
47189 const viewerInfo = getViewerInfo();
@@ -61,6 +203,7 @@ function initPdfAccessibility() {
61203 });
62204 mutationObserver.observe(viewerInfo.container, { childList: true, subtree: true });
63205}
206+
64207// Get viewer container and ID
65208function getViewerInfo() {
66209 const container = document.querySelector('.e-pv-viewer-container');
@@ -119,13 +262,14 @@ function wirePage(div) {
119262 div.addEventListener('click', () => focusPageDiv(div));
120263 div.setAttribute('data-a11y-init', 'true');
121264}
265+
122266// Reader the selected text aloud - Mircosoft Reader
123267 function readAloudText(text) {
124268 window.speechSynthesis.cancel();
125- const utterance = new SpeechSynthesisUtterance(text);
126- window.speechSynthesis.speak(utterance);
269+ speakFromControls(text);
127270}
128- // Cancel speech - Mircosoft Reader
271+
272+ // Cancel speech and remove highlights - Mircosoft Reader
129273 function cancelReading() {
130274 if (window.speechSynthesis?.speaking) {
131275 window.speechSynthesis.cancel();
@@ -209,9 +353,154 @@ Use the browser’s Windows Speech Synthesis API (speechSynthesis) to implement
209353
210354{% tabs %}
211355{% highlight js tabtitle="accessibility.js" %}
356+ const synth = window.speechSynthesis;
357+
358+ let voices = [ ] ;
359+
360+ const voicesReady = new Promise((resolve) => {
361+ const tryGet = () => {
362+ const voice = synth.getVoices();
363+ if (voice && voice.length) {
364+ voices = voice;
365+ resolve(voice);
366+ return true;
367+ }
368+ return false;
369+ };
370+ if (tryGet()) return;
371+ const onVoices = () => {
372+ if (tryGet()) {
373+ synth.removeEventListener('voiceschanged', onVoices);
374+ }
375+ };
376+ synth.addEventListener('voiceschanged', onVoices);
377+ // Fallback polling for browsers that don't fire voiceschanged reliably
378+ let tries = 0;
379+ const poll = setInterval(() => {
380+ if (tryGet() || ++tries > 30) {
381+ clearInterval(poll);
382+ voices = synth.getVoices() || [ ] ;
383+ synth.removeEventListener('voiceschanged', onVoices);
384+ resolve(voices);
385+ }
386+ }, 100);
387+ });
388+
389+ // iOS/iPadOS Safari requires a user gesture before speech works reliably.
390+ // This one-time unlock speaks a silent utterance on first tap/click/keydown.
391+ let __ ttsUnlocked = false;
392+ let __ unlockPromise = null;
393+ function ensureTtsUnlocked() {
394+ if (__ ttsUnlocked) return Promise.resolve();
395+ if (__ unlockPromise) return __ unlockPromise;
396+ __ unlockPromise = new Promise((resolve) => {
397+ const cleanup = () => {
398+ [ 'click', 'touchstart', 'keydown'] .forEach(evt => document.removeEventListener(evt, onEvent, true));
399+ };
400+ const onEvent = () => {
401+ try {
402+ const u = new SpeechSynthesisUtterance(''); // silent token
403+ u.volume = 0;
404+ u.rate = 1;
405+ u.onend = () => {
406+ __ ttsUnlocked = true;
407+ cleanup();
408+ resolve();
409+ };
410+ // Queue in a macrotask to avoid race with gesture handling
411+ setTimeout(() => synth.speak(u), 0);
412+ } catch (_ ) {
413+ __ ttsUnlocked = true;
414+ cleanup();
415+ resolve();
416+ }
417+ };
418+ [ 'click', 'touchstart', 'keydown'] .forEach(evt => document.addEventListener(evt, onEvent, true));
419+ });
420+ return __ unlockPromise;
421+ }
422+
423+ function populateVoiceList() {
424+ voices = synth.getVoices().sort(function (a, b) {
425+ const aname = a.name.toUpperCase();
426+ const bname = b.name.toUpperCase();
427+
428+ if (aname < bname) {
429+ return -1;
430+ } else if (aname == bname) {
431+ return 0;
432+ } else {
433+ return +1;
434+ }
435+ });
436+ }
437+
438+ async function speakFromControls(input) {
439+ // iOS/iPadOS: require user-gesture unlock and stable voice list
440+ await ensureTtsUnlocked().catch(() => { });
441+ await voicesReady;
442+
443+ const t = (typeof input === 'string' ? input.trim() : (input?.value || '').trim());
444+ if (!t) return;
445+
446+ // Cancel any current speech to avoid overlaps/refresh quirks
447+ if (synth.speaking) {
448+ synth.cancel();
449+ }
450+
451+ const utterThis = new SpeechSynthesisUtterance(t);
452+
453+ utterThis.onend = function () {
454+ console.log("SpeechSynthesisUtterance.onend");
455+ };
456+
457+ utterThis.onerror = function (e) {
458+ console.error("SpeechSynthesisUtterance.onerror", e);
459+ };
460+
461+ const available = speechSynthesis.getVoices();
462+ let voice = null;
463+ voice = available.find(v => v.default) || available[0];
464+ if (voice) {
465+ utterThis.voice = voice;
466+ if (voice.lang) utterThis.lang = voice.lang; // Safari iOS respects lang better
467+ }
468+
469+ utterThis.pitch = 1;
470+ utterThis.rate = 1;
471+
472+ // Safari sometimes needs a microtask delay after cancel before speak
473+ setTimeout(() => synth.speak(utterThis), 0);
474+ }
475+
476+ async function initUi() {
477+ await voicesReady;
478+ // Populate voices now that controls exist
479+ populateVoiceList();
480+ if (speechSynthesis.onvoiceschanged !== undefined) {
481+ speechSynthesis.onvoiceschanged = populateVoiceList;
482+ }
483+ return true;
484+ }
485+
486+ // Initialize when DOM is ready; also handle Blazor re-renders
487+ document.addEventListener('DOMContentLoaded', async () => {
488+ // Set up iOS unlock listeners early
489+ ensureTtsUnlocked();
490+ if (await initUi()) return;
491+ const obs = new MutationObserver(async () => {
492+ if (await initUi()) obs.disconnect();
493+ });
494+ obs.observe(document.body, { childList: true, subtree: true });
495+ });
496+
497+ // Expose a helper to manually unlock from .NET or UI (tap/click)
498+ window.unlockTtsForIOS = () => ensureTtsUnlocked();
499+
212500// Register .NET object for interop
213501 function registerDotNetObject(dotNetObj) {
214- window.myDotNetObj = dotNetObj;
502+ window.myDotNetObj = dotNetObj;
503+ ensureTtsUnlocked();
215504}
216505// Read selected text and highlight
217506function readSelectedText(args, zoomLevel, muteVoice) {
@@ -226,7 +515,7 @@ function readSelectedText(args, zoomLevel, muteVoice) {
226515 });
227516 if (muteVoice) return;
228517 requestAnimationFrame(() => {
229- speakText (text, clearAllHighlights );
518+ speakFromControls (text);
230519 });
231520}
232521// Read a line from page and notify .NET
@@ -258,7 +547,7 @@ function readLineFromPage(pageIndex, lineIndex, isPrev, muteVoice) {
258547 if (currentLineSpans && !muteVoice) {
259548 const lineText = currentLineSpans.map(s => s.textContent).join(' ');
260549 requestAnimationFrame(() => {
261- speakText (lineText, clearAllHighlights );
550+ speakFromControls (lineText);
262551 });
263552 }
264553 }
@@ -332,6 +621,7 @@ function getLinesFromPage(pageIndex) {
332621 })
333622 );
334623}
624+
335625// Speak text with optional target voiceUri; on end, invoke callbacks and reset UI
336626function speakText(text, onEnd) {
337627 if (!text || !text.trim()) text = "Warning. No readable text found.";
@@ -451,12 +741,13 @@ function clearAllHighlights() {
451741 clearLineHighlight();
452742 clearSelectedHighlights();
453743}
744+
454745// Pause or resume speech synthesis
455746 function readAloudMute(isPaused) {
456747 const speechSynth = window.speechSynthesis;
457748 isPaused ? speechSynth.resume() : speechSynth.pause();
458749}
459- // Cancel speech and remove highlights
750+ // Cancel speech and remove highlights - Mircosoft Reader
460751 function cancelReading() {
461752 if (window.speechSynthesis?.speaking) {
462753 window.speechSynthesis.cancel();
0 commit comments