@@ -42,6 +42,144 @@ 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+ const available = speechSynthesis.getVoices();
145+ let voice = null;
146+ voice = available.find(v => v.default) || available[0];
147+ if (voice) {
148+ utterThis.voice = voice;
149+ if (voice.lang) utterThis.lang = voice.lang; // Safari iOS respects lang better
150+ }
151+
152+ utterThis.pitch = 1;
153+ utterThis.rate = 1;
154+
155+ // Safari sometimes needs a microtask delay after cancel before speak
156+ setTimeout(() => synth.speak(utterThis), 0);
157+ }
158+
159+ async function initUi() {
160+ await voicesReady;
161+ // Populate voices now that controls exist
162+ populateVoiceList();
163+ if (speechSynthesis.onvoiceschanged !== undefined) {
164+ speechSynthesis.onvoiceschanged = populateVoiceList;
165+ }
166+ return true;
167+ }
168+
169+ // Initialize when DOM is ready; also handle Blazor re-renders
170+ document.addEventListener('DOMContentLoaded', async () => {
171+ // Set up iOS unlock listeners early
172+ ensureTtsUnlocked();
173+ if (await initUi()) return;
174+ const obs = new MutationObserver(async () => {
175+ if (await initUi()) obs.disconnect();
176+ });
177+ obs.observe(document.body, { childList: true, subtree: true });
178+ });
179+
180+ // Expose a helper to manually unlock from .NET or UI (tap/click)
181+ window.unlockTtsForIOS = () => ensureTtsUnlocked();
182+
45183// Initialize PDF accessibility features and observe page changes
46184function initPdfAccessibility() {
47185 const viewerInfo = getViewerInfo();
@@ -61,6 +199,7 @@ function initPdfAccessibility() {
61199 });
62200 mutationObserver.observe(viewerInfo.container, { childList: true, subtree: true });
63201}
202+
64203// Get viewer container and ID
65204function getViewerInfo() {
66205 const container = document.querySelector('.e-pv-viewer-container');
@@ -119,13 +258,14 @@ function wirePage(div) {
119258 div.addEventListener('click', () => focusPageDiv(div));
120259 div.setAttribute('data-a11y-init', 'true');
121260}
261+
122262// Reader the selected text aloud - Mircosoft Reader
123263 function readAloudText(text) {
124264 window.speechSynthesis.cancel();
125- const utterance = new SpeechSynthesisUtterance(text);
126- window.speechSynthesis.speak(utterance);
265+ speakFromControls(text);
127266}
128- // Cancel speech - Mircosoft Reader
267+
268+ // Cancel speech and remove highlights - Mircosoft Reader
129269 function cancelReading() {
130270 if (window.speechSynthesis?.speaking) {
131271 window.speechSynthesis.cancel();
@@ -209,9 +349,148 @@ Use the browser’s Windows Speech Synthesis API (speechSynthesis) to implement
209349
210350{% tabs %}
211351{% highlight js tabtitle="accessibility.js" %}
352+ const synth = window.speechSynthesis;
353+
354+ let voices = [ ] ;
355+
356+ const voicesReady = new Promise((resolve) => {
357+ const tryGet = () => {
358+ const voice = synth.getVoices();
359+ if (voice && voice.length) {
360+ voices = voice;
361+ resolve(voice);
362+ return true;
363+ }
364+ return false;
365+ };
366+ if (tryGet()) return;
367+ const onVoices = () => {
368+ if (tryGet()) {
369+ synth.removeEventListener('voiceschanged', onVoices);
370+ }
371+ };
372+ synth.addEventListener('voiceschanged', onVoices);
373+ // Fallback polling for browsers that don't fire voiceschanged reliably
374+ let tries = 0;
375+ const poll = setInterval(() => {
376+ if (tryGet() || ++tries > 30) {
377+ clearInterval(poll);
378+ voices = synth.getVoices() || [ ] ;
379+ synth.removeEventListener('voiceschanged', onVoices);
380+ resolve(voices);
381+ }
382+ }, 100);
383+ });
384+
385+ // iOS/iPadOS Safari requires a user gesture before speech works reliably.
386+ // This one-time unlock speaks a silent utterance on first tap/click/keydown.
387+ let __ ttsUnlocked = false;
388+ let __ unlockPromise = null;
389+ function ensureTtsUnlocked() {
390+ if (__ ttsUnlocked) return Promise.resolve();
391+ if (__ unlockPromise) return __ unlockPromise;
392+ __ unlockPromise = new Promise((resolve) => {
393+ const cleanup = () => {
394+ [ 'click', 'touchstart', 'keydown'] .forEach(evt => document.removeEventListener(evt, onEvent, true));
395+ };
396+ const onEvent = () => {
397+ try {
398+ const u = new SpeechSynthesisUtterance(''); // silent token
399+ u.volume = 0;
400+ u.rate = 1;
401+ u.onend = () => {
402+ __ ttsUnlocked = true;
403+ cleanup();
404+ resolve();
405+ };
406+ // Queue in a macrotask to avoid race with gesture handling
407+ setTimeout(() => synth.speak(u), 0);
408+ } catch (_ ) {
409+ __ ttsUnlocked = true;
410+ cleanup();
411+ resolve();
412+ }
413+ };
414+ [ 'click', 'touchstart', 'keydown'] .forEach(evt => document.addEventListener(evt, onEvent, true));
415+ });
416+ return __ unlockPromise;
417+ }
418+
419+ function populateVoiceList() {
420+ voices = synth.getVoices().sort(function (a, b) {
421+ const aname = a.name.toUpperCase();
422+ const bname = b.name.toUpperCase();
423+
424+ if (aname < bname) {
425+ return -1;
426+ } else if (aname == bname) {
427+ return 0;
428+ } else {
429+ return +1;
430+ }
431+ });
432+ }
433+
434+ async function speakFromControls(input) {
435+ await voicesReady;
436+
437+ const t = (typeof input === 'string' ? input.trim() : (input?.value || '').trim());
438+ if (!t) return;
439+
440+ // Cancel any current speech to avoid overlaps/refresh quirks
441+ if (synth.speaking) {
442+ synth.cancel();
443+ }
444+
445+ const utterThis = new SpeechSynthesisUtterance(t);
446+
447+ utterThis.onend = function () {
448+ console.log("SpeechSynthesisUtterance.onend");
449+ };
450+
451+ const available = speechSynthesis.getVoices();
452+ let voice = null;
453+ voice = available.find(v => v.default) || available[0];
454+ if (voice) {
455+ utterThis.voice = voice;
456+ if (voice.lang) utterThis.lang = voice.lang; // Safari iOS respects lang better
457+ }
458+
459+ utterThis.pitch = 1;
460+ utterThis.rate = 1;
461+
462+ // Safari sometimes needs a microtask delay after cancel before speak
463+ setTimeout(() => synth.speak(utterThis), 0);
464+ }
465+
466+ async function initUi() {
467+ await voicesReady;
468+ // Populate voices now that controls exist
469+ populateVoiceList();
470+ if (speechSynthesis.onvoiceschanged !== undefined) {
471+ speechSynthesis.onvoiceschanged = populateVoiceList;
472+ }
473+ return true;
474+ }
475+
476+ // Initialize when DOM is ready; also handle Blazor re-renders
477+ document.addEventListener('DOMContentLoaded', async () => {
478+ // Set up iOS unlock listeners early
479+ ensureTtsUnlocked();
480+ if (await initUi()) return;
481+ const obs = new MutationObserver(async () => {
482+ if (await initUi()) obs.disconnect();
483+ });
484+ obs.observe(document.body, { childList: true, subtree: true });
485+ });
486+
487+ // Expose a helper to manually unlock from .NET or UI (tap/click)
488+ window.unlockTtsForIOS = () => ensureTtsUnlocked();
489+
212490// Register .NET object for interop
213491 function registerDotNetObject(dotNetObj) {
214- window.myDotNetObj = dotNetObj;
492+ window.myDotNetObj = dotNetObj;
493+ ensureTtsUnlocked();
215494}
216495// Read selected text and highlight
217496function readSelectedText(args, zoomLevel, muteVoice) {
@@ -226,7 +505,7 @@ function readSelectedText(args, zoomLevel, muteVoice) {
226505 });
227506 if (muteVoice) return;
228507 requestAnimationFrame(() => {
229- speakText (text, clearAllHighlights );
508+ speakFromControls (text);
230509 });
231510}
232511// Read a line from page and notify .NET
@@ -258,7 +537,7 @@ function readLineFromPage(pageIndex, lineIndex, isPrev, muteVoice) {
258537 if (currentLineSpans && !muteVoice) {
259538 const lineText = currentLineSpans.map(s => s.textContent).join(' ');
260539 requestAnimationFrame(() => {
261- speakText (lineText, clearAllHighlights );
540+ speakFromControls (lineText);
262541 });
263542 }
264543 }
@@ -332,6 +611,7 @@ function getLinesFromPage(pageIndex) {
332611 })
333612 );
334613}
614+
335615// Speak text with optional target voiceUri; on end, invoke callbacks and reset UI
336616function speakText(text, onEnd) {
337617 if (!text || !text.trim()) text = "Warning. No readable text found.";
@@ -451,12 +731,13 @@ function clearAllHighlights() {
451731 clearLineHighlight();
452732 clearSelectedHighlights();
453733}
734+
454735// Pause or resume speech synthesis
455736 function readAloudMute(isPaused) {
456737 const speechSynth = window.speechSynthesis;
457738 isPaused ? speechSynth.resume() : speechSynth.pause();
458739}
459- // Cancel speech and remove highlights
740+ // Cancel speech and remove highlights - Mircosoft Reader
460741 function cancelReading() {
461742 if (window.speechSynthesis?.speaking) {
462743 window.speechSynthesis.cancel();
0 commit comments