PageSourceSearch

https://www.rra.gov.rw/typo3conf/ext/rra_ui/Resources/Public/js/asb.js?1784450501

js rra.gov.rw collected 2026-09-27 20:57:15 UTC 84,893 bytes, 2,095 lines download raw bytes

1/**
2 * ASB - Accessibility Settings Bar
3 * version 0.5
4 */
5 
6 
7 
8 
9window.addEventListener('load', () => {
10    function ObterTamanhoFonte(seletorComClasseAcessibilidade) {
11        let tamanho = window.getComputedStyle(seletorComClasseAcessibilidade, null)
12                        .getPropertyValue('font-size');
13        return parseFloat(tamanho);
14    }
15
16    function ControlaTamanhoElementos(seletoresComClasseAcessibilidade, aumentar, normal)
17    {
18        for (let i = 0; i < seletoresComClasseAcessibilidade.length; i++) {
19            const element = seletoresComClasseAcessibilidade[i];      
20            let fontSizeAtual = 0;
21
22            if (normal)
23            {
24                element.style.fontSize = '1em';
25            }
26            else
27            {        
28                if (aumentar)
29                    fontSizeAtual = ObterTamanhoFonte(element) + 1;
30                else
31                    fontSizeAtual = ObterTamanhoFonte(element) - 1;            
32
33                element.style.fontSize = fontSizeAtual.toString() + 'px'; 
34            }
35        } 
36    }
37
38    const incBtn = document.querySelector('#increase-plugin-ac');
39    const normBtn = document.querySelector('#normal-plugin-ac');
40    const decBtn = document.querySelector('#decrease-plugin-ac');
41
42    if (incBtn) {
43        incBtn.addEventListener('click', e => {
44            let acessibilidade = document.getElementsByClassName('accessibility-plugin-ac');
45            ControlaTamanhoElementos(acessibilidade, true);
46        });
47    }
48
49    if (normBtn) {
50        normBtn.addEventListener('click', e => {
51            let acessibilidade = document.getElementsByClassName('accessibility-plugin-ac');
52            ControlaTamanhoElementos(acessibilidade, null, true);
53        });
54    }
55
56    if (decBtn) {
57        decBtn.addEventListener('click', e => {
58            let acessibilidade = document.getElementsByClassName('accessibility-plugin-ac');
59            ControlaTamanhoElementos(acessibilidade, false);
60        });
61    }
62});
63 
64 
65
66        function runProgram()
67        {
68            var shell = new ActiveXObject("WScript.Shell");                 
69            var appWinMerge = "\"C:\\Windows\\notepad.exe\" /e /s /u /wl /wr /maximize";
70            var fileLeft = "\"D:\\Path\\to\\your\\file\"";
71            var fileRight= "\"D:\\Path\\to\\your\\file2\"";
72            shell.Run(appWinMerge + " " + fileLeft + " " + fileRight);
73        }
74    
75  // VoiceOver functionality
76        let currentUtterance = null;
77        let isSpeaking = false;
78        let lastTextToRead = "";
79        let lastVoiceButton = null;
80        let voiceOverEnabled = false;
81        let waitingForPdfText = false;
82        let currentSpeechToken = 0;
83        let focusSpeakEnabled = true;
84        let lastFocusSpokenAt = 0;
85        let lastFocusEl = null;
86        let lastFocusText = '';
87        let quickNavEnabled = false;
88        let lastArrowKeyTime = 0;
89        let lastArrowKey = '';
90        const voiceRateStorageKey = "voiceRate";
91        const voiceVerbosityStorageKey = "voiceVerbosity";
92        const voicePunctuationStorageKey = "voicePunctuation";
93        const voiceRateMin = 0.1;
94        const voiceRateMax = 3.0;
95        const voiceRateStep = 0.2;
96        let voiceRate = parseFloat(sessionStorage.getItem(voiceRateStorageKey)) || 1.0;
97        let voiceVerbosity = sessionStorage.getItem(voiceVerbosityStorageKey) || "verbose";
98        let voicePunctuation = sessionStorage.getItem(voicePunctuationStorageKey) || "none";
99        let voiceSpeakRole = true;
100        let speakTypedChars = true;
101        let lastTypedAt = 0;
102        let lastHoverSpokenAt = 0;
103        let lastHoverEl = null;
104        let lastHoverText = '';
105        let voicePrompted = false;
106        let voiceAutoResume = sessionStorage.getItem('voiceOverEnabled') === 'true';
107
108        // --- Speech language support ---
109        // The page language drives both the synthesis voice (accent) and the
110        // spoken system announcements. TYPO3 may emit lang tags with an
111        // underscore (e.g. "fr_FR"), so normalize to BCP 47 dashes first.
112        function normalizeLangTag(tag) {
113            return (tag || '').toLowerCase().replace(/_/g, '-').trim();
114        }
115
116        function getDocLang() {
117            return normalizeLangTag(document.documentElement.lang) || 'en-us';
118        }
119
120        function getSpeechLang() {
121            return getDocLang().split('-')[0];
122        }
123
124        const speechMessages = {
125            fr: {
126                'Speed': 'Vitesse',
127                'Page ended': 'Fin de la page',
128                'Start of page': 'Début de la page',
129                'Quick Nav on': 'Navigation rapide activée',
130                'Quick Nav off': 'Navigation rapide désactivée',
131                'Press Enter to activate voice over.': 'Appuyez sur Entrée pour activer la lecture vocale.',
132                'space': 'espace',
133                'deleted': 'supprimé',
134                'new line': 'nouvelle ligne',
135                'input field': 'champ de saisie',
136                'Search input field': 'Champ de recherche',
137                'Email input field': "Champ de saisie d'e-mail",
138                'Phone input field': 'Champ de saisie de téléphone',
139                'Password input field': 'Champ de saisie de mot de passe',
140                'Input field': 'Champ de saisie',
141                'Table.': 'Tableau.',
142                'Column headers:': 'En-têtes de colonnes :',
143                'Row': 'Ligne',
144                'End of table.': 'Fin du tableau.',
145                'Test, period. Question? Exclamation! Colon: semicolon; dash - slash /.':
146                    'Test, point. Question ? Exclamation ! Deux points : point virgule ; tiret - barre oblique /.',
147                'Shortcuts.': 'Raccourcis.',
148                'Quick Nav toggle: press Left Arrow and Right Arrow together.':
149                    'Navigation rapide : appuyez sur les flèches gauche et droite en même temps.',
150                'When Quick Nav is on: H for next heading, B for next button, L for next link, F for next form field.':
151                    'Quand la navigation rapide est activée : H pour le titre suivant, B pour le bouton suivant, L pour le lien suivant, F pour le champ de formulaire suivant.',
152                'Arrow Up increases voice speed. Arrow Down decreases voice speed.':
153                    'La flèche haut augmente la vitesse de la voix. La flèche bas diminue la vitesse de la voix.',
154                'Tab moves focus to next control.':
155                    'La touche Tab déplace le focus vers le contrôle suivant.',
156                'Verbosity': 'Verbosité',
157                'Punctuation': 'Ponctuation',
158                'Minimal': 'Minimal',
159                'Compact': 'Compact',
160                'Verbose': 'Détaillée',
161                'All': 'Toutes',
162                'None': 'Aucune'
163            }
164        };
165
166        function speechText(text) {
167            const map = speechMessages[getSpeechLang()];
168            return (map && map[text]) || text;
169        }
170
171        const spokenRoles = {
172            fr: {
173                'navigation': 'navigation',
174                'banner': 'bannière',
175                'main': 'contenu principal',
176                'contentinfo': 'pied de page',
177                'complementary': 'complémentaire',
178                'region': 'région',
179                'link': 'lien',
180                'button': 'bouton',
181                'checkbox': 'case à cocher',
182                'radio': 'bouton radio',
183                'search field': 'champ de recherche',
184                'input': 'champ de saisie',
185                'drop down button': 'bouton déroulant',
186                'listbox': 'liste déroulante',
187                'textbox': 'zone de texte',
188                'image': 'image',
189                'expanded': 'déplié',
190                'collapsed': 'replié'
191            }
192        };
193
194        function translateSpokenRole(role) {
195            if (!role) return role;
196            const map = spokenRoles[getSpeechLang()];
197            if (!map) return role;
198            const heading = role.match(/^heading level (\d)$/);
199            if (heading) return `titre de niveau ${heading[1]}`;
200            return map[role] || role;
201        }
202
203        // Spoken punctuation words per language. Multi-word French equivalents
204        // avoid hyphens/punctuation on purpose: formatPunctuation() replaces
205        // characters sequentially, so an inserted word must not itself contain
206        // a character replaced by a later rule.
207        const punctuationWords = {
208            en: { period: 'period', comma: 'comma', question: 'question mark', exclamation: 'exclamation mark', colon: 'colon', semicolon: 'semicolon', dash: 'dash', slash: 'slash' },
209            fr: { period: 'point', comma: 'virgule', question: "point d'interrogation", exclamation: "point d'exclamation", colon: 'deux points', semicolon: 'point virgule', dash: 'tiret', slash: 'barre oblique' }
210        };
211
212        // Voice selection is cached per page language; browsers load the voice
213        // list asynchronously, so invalidate the cache when it arrives.
214        let cachedVoice = null;
215        let cachedVoiceLang = '';
216
217        if (window.speechSynthesis && typeof window.speechSynthesis.addEventListener === 'function') {
218            window.speechSynthesis.addEventListener('voiceschanged', () => {
219                cachedVoice = null;
220                cachedVoiceLang = '';
221            });
222        }
223
224        // Pre-recorded prompt audio per language, for languages that have no
225        // synthesis voice (e.g. Kinyarwanda). Files live in
226        // EXT:rra_ui/Resources/Public/audio/ — a missing file falls back to TTS.
227        const promptAudioFiles = { rw: 'voiceover-prompt-rw.mp3' };
228
229        function getAssetBasePath() {
230            const script = document.querySelector('script[src*="asb"]');
231            const src = script ? script.getAttribute('src') : '';
232            const marker = src.indexOf('/js/');
233            return marker !== -1 ? src.slice(0, marker + 1) : '';
234        }
235
236        function getPromptAudioUrl() {
237            const file = promptAudioFiles[getSpeechLang()];
238            if (!file) return null;
239            const base = getAssetBasePath();
240            return base ? base + 'audio/' + file : null;
241        }
242
243        function pickVoiceForLang(docLang) {
244            const voices = window.speechSynthesis.getVoices();
245            if (!voices.length) return null;
246            const baseLang = docLang.split('-')[0];
247            return voices.find(v => normalizeLangTag(v.lang) === docLang)
248                || voices.find(v => normalizeLangTag(v.lang).split('-')[0] === baseLang)
249                || voices.find(v => normalizeLangTag(v.lang).split('-')[0] === 'en')
250                || voices[0];
251        }
252
253        function clampVoiceRate(rate) {
254            return Math.max(voiceRateMin, Math.min(voiceRateMax, rate));
255        }
256
257        function setVoiceRate(rate) {
258            voiceRate = clampVoiceRate(rate);
259            sessionStorage.setItem(voiceRateStorageKey, voiceRate.toFixed(2));
260        }
261
262        function updateVoiceRateUI() {
263            const indicator = document.querySelector('span[data-voice-rate-indicator]');
264            if (indicator) {
265                indicator.textContent = `${speechText('Speed')} ${voiceRate.toFixed(1)}x`;
266            }
267        }
268
269        function updateVoiceVerbosityUI() {
270            const indicator = document.querySelector('span[data-voice-verbosity-indicator]');
271            if (indicator) {
272                const label = voiceVerbosity.charAt(0).toUpperCase() + voiceVerbosity.slice(1);
273                indicator.textContent = `${speechText('Verbosity')} ${speechText(label)}`;
274            }
275        }
276
277        function updateVoicePunctuationUI() {
278            const indicator = document.querySelector('span[data-voice-punctuation-indicator]');
279            if (indicator) {
280                const label = voicePunctuation.charAt(0).toUpperCase() + voicePunctuation.slice(1);
281                indicator.textContent = `${speechText('Punctuation')} ${speechText(label)}`;
282            }
283        }
284
285
286        function formatPunctuation(text) {
287            if (!text) return text;
288            if (voicePunctuation === "all") {
289                const words = punctuationWords[getSpeechLang()] || punctuationWords.en;
290                const cleaned = text
291                    .replace(/\./g, ` ${words.period} `)
292                    .replace(/,/g, ` ${words.comma} `)
293                    .replace(/\?/g, ` ${words.question} `)
294                    .replace(/!/g, ` ${words.exclamation} `)
295                    .replace(/:/g, ` ${words.colon} `)
296                    .replace(/;/g, ` ${words.semicolon} `)
297                    .replace(/-/g, ` ${words.dash} `)
298                    .replace(/\//g, ` ${words.slash} `);
299                return cleaned;
300            }
301            if (voicePunctuation === "none") {
302                const cleaned = text.replace(/[.,/#!$%^&*;:{}=\-_`~()[\]"'<>?\\|+]/g, " ");
303                return cleaned;
304            }
305            return text;
306        }
307
308        function applyVerbosity(text) {
309            if (!text) return text;
310            if (voiceVerbosity === "minimal") {
311                const cleaned = text.replace(/\s+/g, " ").trim();
312                return cleaned;
313            }
314            if (voiceVerbosity === "compact") {
315                const cleaned = text.replace(/\s+/g, " ").trim();
316                return cleaned;
317            }
318            return text;
319        }
320
321        function formatSpeech(text) {
322            return applyVerbosity(formatPunctuation(text));
323        }
324
325        function getElementRole(el) {
326            if (!el) return '';
327            const explicitRole = el.getAttribute('role');
328            if (explicitRole) return explicitRole;
329            const tag = el.tagName;
330            if (tag === 'NAV') return 'navigation';
331            if (tag === 'HEADER') return 'banner';
332            if (tag === 'MAIN') return 'main';
333            if (tag === 'FOOTER') return 'contentinfo';
334            if (tag === 'ASIDE') return 'complementary';
335            if (tag === 'SECTION') {
336                const label = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby');
337                if (label) return 'region';
338            }
339            if (tag === 'H1' || tag === 'H2' || tag === 'H3' || tag === 'H4' || tag === 'H5' || tag === 'H6') {
340                const level = tag.substring(1);
341                return `heading level ${level}`;
342            }
343            if (tag === 'A') return 'link';
344            if (tag === 'BUTTON') return 'button';
345            if (tag === 'INPUT') {
346                const type = (el.getAttribute('type') || '').toLowerCase();
347                if (type === 'submit' || type === 'button') return 'button';
348                if (type === 'checkbox') return 'checkbox';
349                if (type === 'radio') return 'radio';
350                if (type === 'search') return 'search field';
351                return 'input';
352            }
353            if (tag === 'SELECT') return 'listbox';
354            if (tag === 'TEXTAREA') return 'textbox';
355            if (tag === 'IMG') return 'image';
356            return '';
357        }
358
359        function composeSpokenText(label, role) {
360            const cleanLabel = (label || '').trim();
361            const cleanRole = translateSpokenRole((role || '').trim());
362
363            if (!cleanLabel) return '';
364            if (!cleanRole) return cleanLabel;
365
366            const normalized = cleanLabel.toLowerCase();
367            if (
368                normalized.endsWith(` ${cleanRole.toLowerCase()}`) ||
369                normalized.endsWith(' input field') ||
370                normalized.endsWith(' search field') ||
371                normalized.endsWith(' email input field') ||
372                normalized.endsWith(' phone input field') ||
373                normalized.endsWith(' password input field') ||
374                normalized.endsWith(' textbox') ||
375                normalized.endsWith(' listbox') ||
376                normalized.endsWith(' button') ||
377                normalized.endsWith(' link') ||
378                normalized.endsWith(' champ de saisie') ||
379                normalized.endsWith(' champ de recherche') ||
380                normalized.endsWith(" champ de saisie d'e-mail") ||
381                normalized.endsWith(' champ de saisie de téléphone') ||
382                normalized.endsWith(' champ de saisie de mot de passe')
383            ) {
384                return cleanLabel;
385            }
386
387            return `${cleanLabel} ${cleanRole}`;
388        }
389
390        function buildUtterance(text) {
391            const utterance = new SpeechSynthesisUtterance(formatSpeech(text));
392            const docLang = getDocLang();
393            if (!cachedVoice || cachedVoiceLang !== docLang) {
394                cachedVoice = pickVoiceForLang(docLang);
395                cachedVoiceLang = docLang;
396            }
397            if (cachedVoice) {
398                utterance.voice = cachedVoice;
399                utterance.lang = cachedVoice.lang;
400            } else {
401                utterance.lang = docLang;
402            }
403            utterance.pitch = 1;
404            utterance.rate = voiceRate;
405            utterance.volume = 1;
406            utterance.onend = function() {
407                resetVoiceOverUi();
408            };
409            return utterance;
410        }
411
412        function resetVoiceOverUi() {
413            isSpeaking = false;
414            currentUtterance = null;
415            if (lastVoiceButton) {
416                lastVoiceButton.classList.remove('voice-active');
417            }
418            document.querySelectorAll('.reading-highlight').forEach(el => {
419                el.classList.remove('reading-highlight');
420            });
421        }
422
423        function normalizeReadableText(text) {
424            return (text || '')
425                .replace(/\r/g, '')
426                .replace(/\u00a0/g, ' ')
427                .replace(/[ \t]+\n/g, '\n')
428                .replace(/\n{3,}/g, '\n\n')
429                .replace(/[ \t]{2,}/g, ' ')
430                .trim();
431        }
432
433        function splitTextForSpeech(text, maxLength = 1200) {
434            const normalizedText = normalizeReadableText(text);
435            if (!normalizedText) return [];
436
437            const chunks = [];
438            const paragraphs = normalizedText.split(/\n{2,}/).map(part => part.trim()).filter(Boolean);
439
440            function pushChunk(chunk) {
441                const cleaned = normalizeReadableText(chunk);
442                if (cleaned) {
443                    chunks.push(cleaned);
444                }
445            }
446
447            function splitLongSegment(segment) {
448                const sentences = segment.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [segment];
449                let currentChunk = '';
450
451                sentences.forEach(function (sentence) {
452                    sentence = normalizeReadableText(sentence);
453                    if (!sentence) return;
454
455                    if (sentence.length > maxLength) {
456                        if (currentChunk) {
457                            pushChunk(currentChunk);
458                            currentChunk = '';
459                        }
460
461                        const words = sentence.split(/\s+/);
462                        let wordChunk = '';
463
464                        words.forEach(function (word) {
465                            const candidate = wordChunk ? wordChunk + ' ' + word : word;
466                            if (candidate.length > maxLength && wordChunk) {
467                                pushChunk(wordChunk);
468                                wordChunk = word;
469                            } else {
470                                wordChunk = candidate;
471                            }
472                        });
473
474                        if (wordChunk) {
475                            pushChunk(wordChunk);
476                        }
477                        return;
478                    }
479
480                    const candidate = currentChunk ? currentChunk + ' ' + sentence : sentence;
481                    if (candidate.length > maxLength && currentChunk) {
482                        pushChunk(currentChunk);
483                        currentChunk = sentence;
484                    } else {
485                        currentChunk = candidate;
486                    }
487                });
488
489                if (currentChunk) {
490                    pushChunk(currentChunk);
491                }
492            }
493
494            paragraphs.forEach(splitLongSegment);
495            return chunks.length ? chunks : [normalizedText];
496        }
497
498        function speakTextInChunks(text) {
499            const chunks = splitTextForSpeech(text);
500            if (!chunks.length) return false;
501
502            currentSpeechToken += 1;
503            const speechToken = currentSpeechToken;
504            let chunkIndex = 0;
505
506            function speakNextChunk() {
507                if (speechToken !== currentSpeechToken) return;
508
509                if (chunkIndex >= chunks.length) {
510                    resetVoiceOverUi();
511                    return;
512                }
513
514                currentUtterance = buildUtterance(chunks[chunkIndex]);
515                currentUtterance.onend = function () {
516                    if (speechToken !== currentSpeechToken) return;
517                    chunkIndex += 1;
518                    speakNextChunk();
519                };
520                currentUtterance.onerror = function () {
521                    if (speechToken !== currentSpeechToken) return;
522                    chunkIndex += 1;
523                    speakNextChunk();
524                };
525                window.speechSynthesis.speak(currentUtterance);
526                window.speechSynthesis.resume();
527            }
528
529            isSpeaking = true;
530            speakNextChunk();
531            return true;
532        }
533
534        function restartSpeechIfActive() {
535            if (!isSpeaking || !lastTextToRead) return;
536            currentSpeechToken += 1;
537            window.speechSynthesis.cancel();
538            speakTextInChunks(lastTextToRead);
539        }
540
541        // Serialize a data table for speech: announce all column headers
542        // first, then read row by row with each cell prefixed by the column
543        // header it belongs to.
544        function serializeTableForSpeech(table) {
545            // Hidden tables (e.g. inside a collapsed accordion) are skipped,
546            // matching how innerText excludes hidden content elsewhere.
547            if (table.offsetParent === null) return '';
548
549            let headerCells = Array.from(table.querySelectorAll('thead th'));
550            if (!headerCells.length) {
551                const firstRow = table.querySelector('tr');
552                if (firstRow) headerCells = Array.from(firstRow.querySelectorAll('th'));
553            }
554            if (!headerCells.length) {
555                // Many pasted/CMS-generated tables never use <th> at all - fall back to the
556                // first row's cells (whatever tag) as the column titles.
557                const firstRow = table.querySelector('tr');
558                if (firstRow) headerCells = Array.from(firstRow.querySelectorAll('td, th'));
559            }
560            const headers = headerCells.map(th => (th.innerText || '').replace(/\s+/g, ' ').trim());
561
562            const bodyRows = Array.from(table.querySelectorAll('tr')).filter(tr => {
563                if (headerCells.length && tr.contains(headerCells[0])) return false;
564                return tr.querySelectorAll('td, th').length > 0;
565            });
566
567            const parts = [];
568            const caption = table.querySelector('caption');
569            parts.push(speechText('Table.') + (caption ? ' ' + (caption.innerText || '').trim() : ''));
570            let hasContent = false;
571
572            // Announce each column title by name with its column number, e.g. "Fees row 1.
573            // Rate row 2." - not a bare "Row 1" / "Row 2" with no title.
574            if (headers.some(Boolean)) {
575                hasContent = true;
576                parts.push(headers.map((h, i) => h ? `${h} ${speechText('Row')} ${i + 1}
576` : '').filter(Boolean).join('. ') + '.');
577            }
578
579            // Each data cell is read as "content header" so the column title is spoken once,
580            // right after its value (e.g. "Small vehicles Fees") - no "Row N" prefix, just the
581            // column title each cell belongs to. If a cell's own text already matches its
582            // header (or the header is missing), the header suffix is dropped so nothing is
583            // spoken twice (e.g. "Fees Fees").
584            bodyRows.forEach((tr) => {
585                const cells = Array.from(tr.querySelectorAll('th, td'));
586                // A row that doesn't actually map one-to-one onto the columns (fewer cells than
587                // headers, or a cell explicitly spanning multiple columns) is a section/divider
588                // row, not real column data - e.g. "Public Parking on road" spanning both the
589                // "Fees" and "Rate" columns. Its own text is read plainly, with no column title
590                // attached, instead of being wrongly tagged with the first column's title.
591                const isSpanningRow = cells.length < headers.length ||
592                    cells.some(c => (parseInt(c.getAttribute('colspan'), 10) || 1) > 1);
593                const cellParts = cells.map((cell, i) => {
594                    const value = (cell.innerText || '').replace(/\s+/g, ' ').trim();
595                    if (!value) return '';
596                    if (isSpanningRow) return value;
597                    const header = headers[i] || cell.getAttribute('data-label') || '';
598                    if (!header || header.toLowerCase() === value.toLowerCase()) return value;
599                    return `${value} ${header}`;
600                }).filter(Boolean);
601                if (cellParts.length) {
602                    hasContent = true;
603                    parts.push(`${cellParts.join('. ')}.`);
604                }
605            });
606
607            if (!hasContent) return '';
608            parts.push(speechText('End of table.'));
609            return parts.join('\n');
610        }
611
612        // A landmark section (header/nav/footer/[role=region] etc. with an aria-label) gets its
613        // name announced before its content, the same way a page/document title would be.
614        function getLandmarkLabel(el) {
615            if (!el || !el.getAttribute) return '';
616            const label = el.getAttribute('aria-label');
617            if (!label || !label.trim()) return '';
618            const tag = el.tagName;
619            const role = (el.getAttribute('role') || '').toLowerCase();
620            const isLandmark = tag === 'SECTION' || tag === 'NAV' || tag === 'HEADER' || tag === 'FOOTER' ||
621                role === 'region' || role === 'navigation' || role === 'banner' || role === 'contentinfo';
622            return isLandmark ? label.trim() : '';
623        }
624
625        const LANDMARK_SELECTOR = 'table, section[aria-label], nav[aria-label], header[aria-label], ' +
626            'footer[aria-label], [role="region"][aria-label], [role="navigation"][aria-label], ' +
627            '[role="banner"][aria-label], [role="contentinfo"][aria-label]';
628
629        // Page text for the voice-over, with tables replaced by their
630        // header-aware spoken form and labelled landmark sections announced by name first.
631        function getReadableTextWithTables(root) {
632            if (!root.querySelector || !root.querySelector(LANDMARK_SELECTOR)) {
633                return root.innerText || root.textContent || '';
634            }
635
636            function walk(el, announceOwnLabel) {
637                if (el.tagName === 'TABLE') return serializeTableForSpeech(el);
638                const label = announceOwnLabel ? getLandmarkLabel(el) : '';
639                if (!el.querySelector(LANDMARK_SELECTOR)) {
640                    const text = (el.innerText || el.textContent || '').trim();
641                    if (!text) return '';
642                    return label ? `${label}.\n${text}` : text;
643                }
644                const parts = [];
645                if (label) parts.push(label + '.');
646                Array.from(el.childNodes).forEach(node => {
647                    if (node.nodeType === Node.TEXT_NODE) {
648                        const t = (node.textContent || '').trim();
649                        if (t) parts.push(t);
650                    } else if (node.nodeType === Node.ELEMENT_NODE) {
651                        const t = walk(node, true);
652                        if (t && t.trim()) parts.push(t.trim());
653                    }
654                });
655                return parts.join('\n');
656            }
657
658            return walk(root, false);
659        }
660
661        function getTextFromElementToEnd(el, afterEl) {
662            const mainContent = document.querySelector('main, [role="main"], #content') || document.body;
663            if (!mainContent.contains(el)) {
664                return normalizeReadableText(getReadableTextWithTables(mainContent));
665            }
666            const range = document.createRange();
667            try {
668                range.selectNodeContents(mainContent);
669                if (afterEl) {
670                    range.setStartAfter(el);
671                } else {
672                    range.setStartBefore(el);
673                }
674            } catch (e) {
675                return normalizeReadableText(getReadableTextWithTables(mainContent));
676            }
677            const fragment = range.cloneContents();
678            const tempDiv = document.createElement('div');
679            tempDiv.appendChild(fragment);
680            // Cloned <link>/<script>/<iframe>/etc. elements re-trigger their network request
681            // (or reload) the moment they're inserted into the live document below, even though
682            // they were only carried along incidentally by cloneContents(). Strip anything that
683            // isn't readable content before attaching the clone.
684            tempDiv.querySelectorAll('link, script, style, iframe, object, embed, noscript, video, audio').forEach(function (node) {
685                node.remove();
686            });
687            // innerText and offsetParent (used to skip hidden tables/content, see
688            // serializeTableForSpeech) only compute correctly on elements that are actually
689            // rendered. A fully detached div makes everything look "hidden", which silently
690            // drops tables and, worse, makes textContent fall through and start reading
691            // genuinely hidden/collapsed content. Render the clone off-screen instead.
692            tempDiv.style.position = 'absolute';
693            tempDiv.style.left = '-99999px';
694            tempDiv.style.top = '0';
695            document.body.appendChild(tempDiv);
696            try {
697                return normalizeReadableText(getReadableTextWithTables(tempDiv));
698            } finally {
699                tempDiv.remove();
700            }
701        }
702
703        function getVoiceOverTextSource() {
704            const isPdfReaderPage = window.location.pathname.indexOf('pdf-reader.php') !== -1;
705            if (isPdfReaderPage) {
706                const pdfReaderState = document.body.getAttribute('data-pdf-reader');
707                if (pdfReaderState === 'loading') {
708                    return { loading: true, text: '' };
709                }
710
711                const extractedTextContainer = document.getElementById('pdf-extracted-text');
712                const extractedText = extractedTextContainer
713                    ? Array.from(extractedTextContainer.querySelectorAll('p'))
714                        .map(function (paragraph) {
715                            return paragraph.textContent || '';
716                        })
717                        .join('\n\n')
718                    : '';
719                const title = document.getElementById('pdf-title')?.textContent || '';
720                const meta = document.getElementById('pdf-meta')?.textContent || '';
721                const text = normalizeReadableText([title, meta, extractedText].filter(Boolean).join('\n\n'));
722                return { loading: false, text: text };
723            }
724
725            const mainContent = document.querySelector('main, [role="main"], #content') || document.body;
726            const text = normalizeReadableText(getReadableTextWithTables(mainContent));
727            return { loading: false, text: text };
728        }
729
730        function getElementLabel(el) {
731            if (!el) return '';
732            const ariaLabel = el.getAttribute('aria-label');
733            if (ariaLabel && ariaLabel.trim()) {
734                return ariaLabel.trim();
735            }
736
737            if (el.tagName === 'A') {
738                const img = el.querySelector('img');
739                if (img) {
740                    const alt = img.getAttribute('alt');
741                    if (alt && alt.trim()) return alt.trim();
742                }
743            }
744
745            const labelledBy = el.getAttribute('aria-labelledby');
746            if (labelledBy) {
747                const labelEl = document.getElementById(labelledBy);
748                if (labelEl && labelEl.textContent) return labelEl.textContent.trim();
749            }
750
751            const title = el.getAttribute('title');
752            if (title && title.trim()) return title.trim();
753
754            if (el.tagName === 'IMG') {
755                const alt = el.getAttribute('alt');
756                if (alt && alt.trim()) return alt.trim();
757            }
758
759            const tag = el.tagName;
760            if ((tag === 'INPUT' || tag === 'TEXTAREA') && !el.getAttribute('aria-label')) {
761                const type = (el.getAttribute('type') || '').toLowerCase();
762                const placeholder = el.getAttribute('placeholder');
763                if (placeholder && placeholder.trim()) {
764                    return `${placeholder.trim()} ${speechText('input field')}`;
765                }
766                if (type === 'search') return speechText('Search input field');
767                if (type === 'email') return speechText('Email input field');
768                if (type === 'tel') return speechText('Phone input field');
769                if (type === 'password') return speechText('Password input field');
770                return speechText('Input field');
771            }
772
773            // Table cells: read as "content column-title", matching serializeTableForSpeech's
774            // format for the same reason - content first, title after, and never both when the
775            // cell's own text already equals the title (table-a11y.js fills data-label from the thead).
776            if (tag === 'TD' && el.getAttribute('data-label')) {
777                const cellText = (el.innerText || el.textContent || '').trim();
778                if (!cellText) return '';
779                const label = el.getAttribute('data-label');
780                if (!label || label.toLowerCase() === cellText.toLowerCase()) return cellText;
781                return `${cellText} ${label}`;
782            }
783
784            const text = (el.innerText || el.textContent || '').trim();
785            return text;
786        }
787
788        function getExpandedStateWord(el) {
789            if (!el || !el.getAttribute) return '';
790
791            // Prefer the controlled element's real visibility (Bootstrap adds/removes `.show` on the target).
792            const controlsId = el.getAttribute('aria-controls');
793            if (controlsId) {
794                const controlledEl = document.getElementById(controlsId);
795                if (controlledEl && controlledEl.classList) {
796                    return controlledEl.classList.contains('show') ? 'expanded' : 'collapsed';
797                }
798            }
799
800            // Fallbacks (some toggles use class "collapsed" reliably)
801            if (el.classList && el.classList.contains('collapsed')) return 'collapsed';
802
803            const expanded = el.getAttribute('aria-expanded');
804            if (expanded === 'true') return 'expanded';
805            if (expanded === 'false') return 'collapsed';
806            return '';
807        }
808
809        function isToggleButton(el) {
810            if (!el) return false;
811            if (el.tagName === 'BUTTON') return true;
812            const role = (el.getAttribute && el.getAttribute('role')) || '';
813            return role.toLowerCase() === 'button';
814        }
815
816        // Buttons that open a menu (aria-haspopup) are announced as
817        // "drop down button" instead of their expanded/collapsed state.
818        function isDropdownTrigger(el) {
819            if (!el || !el.getAttribute) return false;
820            const popup = (el.getAttribute('aria-haspopup') || '').toLowerCase();
821            return popup === 'true' || popup === 'menu' || popup === 'listbox';
822        }
823
824        function getToggleObjectLabel(el) {
825            if (!el) return '';
826
827            const controlsId = el.getAttribute && el.getAttribute('aria-controls');
828            if (controlsId) {
829                const controlledEl = document.getElementById(controlsId);
830                const controlledLabel = controlledEl && controlledEl.getAttribute && controlledEl.getAttribute('aria-label');
831                if (controlledLabel && controlledLabel.trim()) return controlledLabel.trim();
832            }
833
834            const raw = getElementLabel(el);
835            if (!raw) return '';
836
837            // Prefer object label over action/state label.
838            // This strips action words and any trailing state words so we don't produce
839            // phrases like "collapsed collapsed button".
840            return raw
841                .replace(/^(open|close|collapse|expand)\s+/i, '')
842                .replace(/\b(is\s+)?(expanded|collapsed)\b/gi, '')
843                .replace(/\bbutton\b/gi, '')
844                .replace(/\s+/g, ' ')
845                .trim();
846        }
847
848        // When continuing to read after a toggle button, a collapsed panel's content must be
849        // skipped entirely (jump straight to whatever comes after it, e.g. the next accordion
850        // item) rather than read - it's not visible yet. An expanded panel is read normally,
851        // since "continue after the button" naturally flows straight into its visible content.
852        function getContinueFromElement(el) {
853            if (isToggleButton(el)) {
854                const controlsId = el.getAttribute && el.getAttribute('aria-controls');
855                const controlled = controlsId ? document.getElementById(controlsId) : null;
856                if (controlled && getExpandedStateWord(el) === 'collapsed') {
857                    return controlled;
858                }
859            }
860            return el;
861        }
862
863        function speakFocusElement(el) {
864            if (!voiceOverEnabled || !focusSpeakEnabled) return;
865
866            // Interactive elements (inputs, toggle buttons, dropdowns, links, ...) always get
867            // their own specific announcement first - they must never be swept raw into the
868            // "continue reading" text below, since that would announce their literal text
869            // instead of role/state and could read hidden collapsed content. After announcing,
870            // reading continues automatically into the rest of the content instead of stopping.
871            const spoken = getSpokenTextForElement(el);
872            const now = Date.now();
873
874            // This dedupe must run regardless of isSpeaking: focusin/mousemove-driven calls
875            // can re-fire for an element that's already being read, and cancelling + restarting
876            // speech synthesis on every one of those repeat events (rather than only on a real
877            // change of target) can starve it so badly that nothing audible ever plays.
878            if (el === lastFocusEl && spoken === lastFocusText && (now - lastFocusSpokenAt) < 800) return;
879            if (!isSpeaking && now - lastFocusSpokenAt < 200) return;
880
881            lastFocusSpokenAt = now;
882            lastFocusEl = el;
883            lastFocusText = spoken;
884
885            currentSpeechToken += 1;
886            window.speechSynthesis.cancel();
887
888            if (spoken) {
889                const restText = getTextFromElementToEnd(getContinueFromElement(el), true);
890                const combined = restText.trim() ? `${spoken}\n\n${restText}` : spoken;
891                lastTextToRead = combined;
892                speakTextInChunks(combined);
893            } else {
894                const textFromEl = getTextFromElementToEnd(el);
895                if (textFromEl.trim()) {
896                    lastTextToRead = textFromEl;
897                    speakTextInChunks(textFromEl);
898                } else {
899                    resetVoiceOverUi();
900                }
901            }
902        }
903
904        function getSpokenTextForElement(el) {
905            if (!el) return '';
906            if (isDropdownTrigger(el) && isToggleButton(el)) {
907                const objLabel = getToggleObjectLabel(el);
908                const dropWord = translateSpokenRole('drop down button');
909                return objLabel ? `${objLabel} ${dropWord}` : dropWord;
910            }
911
912            const stateWord = getExpandedStateWord(el);
913            if (stateWord && isToggleButton(el)) {
914                const objLabel = getToggleObjectLabel(el);
915                const spokenState = translateSpokenRole(stateWord);
916                const buttonWord = translateSpokenRole('button');
917                return objLabel ? `${objLabel} ${spokenState} ${buttonWord}` : `${spokenState} ${buttonWord}`;
918            }
919
920            const label = getElementLabel(el);
921            if (!label) return '';
922
923            const role = voiceSpeakRole ? getElementRole(el) : '';
924            return composeSpokenText(label, role);
925        }
926
927        function speakHoverElement(el) {
928            if (!voiceOverEnabled) return;
929            if (!el) return;
930
931            // Same reasoning as speakFocusElement: interactive/labelled elements always get
932            // their own announcement first, then reading continues into the rest of the
933            // content automatically instead of stopping after one element.
934            const spoken = getSpokenTextForElement(el);
935            const now = Date.now();
936
937            // Same reasoning as speakFocusElement above: mousemove fires very frequently, so
938            // this dedupe has to apply even while isSpeaking, or a still mouse over one element
939            // cancels + restarts speech dozens of times a second and nothing ever plays.
940            if (el === lastHoverEl && spoken === lastHoverText) return;
941            if (!isSpeaking && now - lastHoverSpokenAt < 250) return;
942
943            lastHoverSpokenAt = now;
944            lastHoverEl = el;
945            lastHoverText = spoken;
946
947            currentSpeechToken += 1;
948            window.speechSynthesis.cancel();
949
950            if (spoken) {
951                const restText = getTextFromElementToEnd(getContinueFromElement(el), true);
952                const combined = restText.trim() ? `${spoken}\n\n${restText}` : spoken;
953                lastTextToRead = combined;
954                speakTextInChunks(combined);
955            } else {
956                const textFromEl = getTextFromElementToEnd(el);
957                if (textFromEl.trim()) {
958                    lastTextToRead = textFromEl;
959                    speakTextInChunks(textFromEl);
960                } else {
961                    resetVoiceOverUi();
962                }
963            }
964        }
965
966        function getHoverTarget(el) {
967            if (!el) return null;
968            // Skip the PDF reader's canvas page + text layer. PDF.js generates one span per
969            // word and tags the text layer container with role="presentation", which would
970            // make the voice-over announce "<word> presentation" on every hover.
971            if (el.closest && el.closest('#pdf-viewer-wrap')) return null;
972            const hoverable = el.closest(
973                'a[href], button, input, textarea, select, [role="button"], [role="link"], ' +
974                'h1, h2, h3, h4, h5, h6, img[alt], [aria-label], [aria-labelledby], ' +
975                'p, li, label, span, strong, em, small, td, th, blockquote, figcaption, div'
976            );
977            if (!hoverable) return null;
978
979            const tag = hoverable.tagName;
980            if (tag === 'DIV') {
981                const text = (hoverable.innerText || '').trim();
982                if (!text) return null;
983                if (text.length > 200) return null;
984            }
985            return hoverable;
986        }
987
988        function speakTypedChar(char, el) {
989            if (!voiceOverEnabled || !speakTypedChars) return;
990            if (!el) return;
991            const tag = el.tagName;
992            if (tag === 'INPUT') {
993                const type = (el.getAttribute('type') || '').toLowerCase();
994                if (type === 'password') return;
995            }
996
997            const now = Date.now();
998            if (now - lastTypedAt < 40) return;
999            lastTypedAt = now;
1000
1001            let spoken = '';
1002            if (char === ' ') spoken = speechText('space');
1003            else if (char === 'Backspace') spoken = speechText('deleted');
1004            else if (char === 'Enter') spoken = speechText('new line');
1005            else if (char && char.length === 1) spoken = char;
1006            else return;
1007
1008            window.speechSynthesis.cancel();
1009            window.speechSynthesis.speak(buildUtterance(spoken));
1010            window.speechSynthesis.resume();
1011        }
1012
1013        function speakQuickNavStatus(enabled) {
1014            if (!voiceOverEnabled) return;
1015            const utterance = buildUtterance(speechText(enabled ? 'Quick Nav on' : 'Quick Nav off'));
1016            window.speechSynthesis.cancel();
1017            window.speechSynthesis.speak(utterance);
1018            window.speechSynthesis.resume();
1019        }
1020
1021        function isTypingContext(el) {
1022            if (!el) return false;
1023            const tag = el.tagName;
1024            if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
1025            if (el.isContentEditable) return true;
1026            return false;
1027        }
1028
1029        function getQuickNavTargets(type) {
1030            switch (type) {
1031                case 'heading':
1032                    return Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
1033                case 'button':
1034                    return Array.from(document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]'));
1035                case 'link':
1036                    return Array.from(document.querySelectorAll('a[href]'));
1037                case 'form':
1038                    return Array.from(document.querySelectorAll(
1039                        'input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="checkbox"]):not([type="radio"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), [contenteditable="true"]'
1040                    ));
1041                default:
1042                    return [];
1043            }
1044        }
1045
1046        function moveQuickNav(type) {
1047            const items = getQuickNavTargets(type);
1048            if (!items.length) return;
1049
1050            const active = document.activeElement;
1051            let idx = items.indexOf(active);
1052            if (idx === -1) {
1053                const activePos = active && active.getBoundingClientRect ? active.getBoundingClientRect().top : -Infinity;
1054                idx = items.findIndex(el => {
1055                    const rect = el.getBoundingClientRect();
1056                    return rect.top > activePos;
1057                });
1058                if (idx === -1) idx = 0;
1059            } else {
1060                idx = (idx + 1) % items.length;
1061            }
1062
1063            const target = items[idx];
1064            if (target) {
1065                target.setAttribute('tabindex', target.getAttribute('tabindex') || '0');
1066                target.focus({ preventScroll: false });
1067                speakFocusElement(target);
1068            }
1069        }
1070
1071        document.addEventListener('focusin', (e) => {
1072            speakFocusElement(e.target);
1073        });
1074
1075        document.addEventListener('mousemove', (e) => {
1076            if (!voiceOverEnabled) return;
1077            if (isTypingContext(document.activeElement)) return;
1078
1079            const hoveredElement = document.elementFromPoint(e.clientX, e.clientY);
1080            const target = getHoverTarget(hoveredElement);
1081            if (!target) return;
1082            speakHoverElement(target);
1083        });
1084
1085        // Touch devices don't have true "hover". This implements "explore by touch":
1086        // when Voice Over is enabled, speak the element under the finger.
1087        function handleTouchExplore(e) {
1088            if (!voiceOverEnabled) return;
1089            if (!e.touches || e.touches.length !== 1) return;
1090
1091            const touch = e.touches[0];
1092            const hoveredElement = document.elementFromPoint(touch.clientX, touch.clientY);
1093            const target = getHoverTarget(hoveredElement);
1094            if (!target) return;
1095            speakHoverElement(target);
1096        }
1097
1098        // Use passive listeners so we don't interfere with scroll performance.
1099        document.addEventListener('touchstart', handleTouchExplore, { passive: true });
1100        document.addEventListener('touchmove', handleTouchExplore, { passive: true });
1101
1102        document.addEventListener('keydown', (e) => {
1103            if (voiceOverEnabled) {
1104                if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
1105                    const now = Date.now();
1106                    if (lastArrowKey && lastArrowKey !== e.key && (now - lastArrowKeyTime) < 300) {
1107                        quickNavEnabled = !quickNavEnabled;
1108                        speakQuickNavStatus(quickNavEnabled);
1109                        lastArrowKey = '';
1110                        lastArrowKeyTime = 0;
1111                        e.preventDefault();
1112                    } else {
1113                        lastArrowKey = e.key;
1114                        lastArrowKeyTime = now;
1115                    }
1116                }
1117
1118                if (quickNavEnabled && !isTypingContext(e.target)) {
1119                    const key = (e.key || '').toLowerCase();
1120                    if (key === 'h') {
1121                        e.preventDefault();
1122                        moveQuickNav('heading');
1123                        return;
1124                    }
1125                    if (key === 'b') {
1126                        e.preventDefault();
1127                        moveQuickNav('button');
1128                        return;
1129                    }
1130                    if (key === 'l') {
1131                        e.preventDefault();
1132                        moveQuickNav('link');
1133                        return;
1134                    }
1135                    if (key === 'f') {
1136                        e.preventDefault();
1137                        moveQuickNav('form');
1138                        return;
1139                    }
1140                    if (e.key === 'ArrowUp') {
1141                        e.preventDefault();
1142                        setVoiceRate(voiceRate + voiceRateStep);
1143                        updateVoiceRateUI();
1144                        if (isSpeaking) {
1145                            restartSpeechIfActive();
1146                        } else if (voiceOverEnabled) {
1147                            const sample = `${speechText('Speed')} ${voiceRate.toFixed(1)}x`;
1148                            window.speechSynthesis.cancel();
1149                            window.speechSynthesis.speak(buildUtterance(sample));
1150                            window.speechSynthesis.resume();
1151                        }
1152                        return;
1153                    }
1154                    if (e.key === 'ArrowDown') {
1155                        e.preventDefault();
1156                        setVoiceRate(voiceRate - voiceRateStep);
1157                        updateVoiceRateUI();
1158                        if (isSpeaking) {
1159                            restartSpeechIfActive();
1160                        } else if (voiceOverEnabled) {
1161                            const sample = `${speechText('Speed')} ${voiceRate.toFixed(1)}x`;
1162                            window.speechSynthesis.cancel();
1163                            window.speechSynthesis.speak(buildUtterance(sample));
1164                            window.speechSynthesis.resume();
1165                        }
1166                        return;
1167                    }
1168                }
1169            }
1170
1171            if (e.key === 'Tab') {
1172                const focusables = Array.from(document.querySelectorAll(
1173                    'a[href], button, input, textarea, select, [tabindex]:not
1173([tabindex="-1"])'
1174                )).filter(el => !el.hasAttribute('disabled') && el.offsetParent !== null);
1175                const currentIndex = focusables.indexOf(document.activeElement);
1176                if (!e.shiftKey && currentIndex !== -1 && currentIndex === focusables.length - 1) {
1177                    window.speechSynthesis.cancel();
1178                    window.speechSynthesis.speak(buildUtterance(speechText('Page ended')));
1179                    window.speechSynthesis.resume();
1180                    if (voiceOverEnabled) {
1181                        e.preventDefault();
1182                        return;
1183                    }
1184                }
1185                if (e.shiftKey && currentIndex === 0) {
1186                    window.speechSynthesis.cancel();
1187                    window.speechSynthesis.speak(buildUtterance(speechText('Start of page')));
1188                    window.speechSynthesis.resume();
1189                    if (voiceOverEnabled) {
1190                        e.preventDefault();
1191                        return;
1192                    }
1193                }
1194            }
1195        });
1196
1197        document.addEventListener('input', (e) => {
1198            const el = e.target;
1199            if (!el) return;
1200            const tag = el.tagName;
1201            if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !el.isContentEditable) return;
1202            const value = el.value || '';
1203            if (!value) return;
1204            const char = value.slice(-1);
1205            speakTypedChar(char, el);
1206        });
1207
1208        document.addEventListener('keydown', (e) => {
1209            const el = e.target;
1210            if (!el) return;
1211            const tag = el.tagName;
1212            if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !el.isContentEditable) return;
1213            if (e.key === 'Backspace' || e.key === 'Enter') {
1214                speakTypedChar(e.key, el);
1215            }
1216        });
1217
1218        function toggleVoiceOver(button) {
1219            if (!window.speechSynthesis) {
1220                alert('Text-to-speech is not supported in your browser.');
1221                return;
1222            }
1223            
1224            if (isSpeaking || waitingForPdfText) {
1225                currentSpeechToken += 1;
1226                window.speechSynthesis.cancel();
1227                waitingForPdfText = false;
1228                resetVoiceOverUi();
1229                button.classList.remove('voice-active');
1230                voiceOverEnabled = false;
1231                lastHoverEl = null;
1232                lastHoverText = '';
1233                sessionStorage.setItem('voiceOverEnabled', 'false');
1234                return;
1235            }
1236
1237            const source = getVoiceOverTextSource();
1238            if (source.loading) {
1239                waitingForPdfText = true;
1240                lastVoiceButton = button;
1241                button.classList.add('voice-active');
1242                voiceOverEnabled = true;
1243                lastHoverEl = null;
1244                lastHoverText = '';
1245                sessionStorage.setItem('voiceOverEnabled', 'true');
1246                window.addEventListener('asb:pdf-text-ready', function () {
1247                    if (!waitingForPdfText || isSpeaking || !voiceOverEnabled) return;
1248                    waitingForPdfText = false;
1249                    toggleVoiceOver(button);
1250                }, { once: true });
1251                return;
1252            }
1253
1254            const textToRead = source.text;
1255            
1256            if (!textToRead.trim()) {
1257                alert('No content found to read.');
1258                return;
1259            }
1260            
1261            lastTextToRead = textToRead;
1262            lastVoiceButton = button;
1263            if (!speakTextInChunks(textToRead)) {
1264                alert('No content found to read.');
1265                return;
1266            }
1267            button.classList.add('voice-active');
1268            voiceOverEnabled = true;
1269            lastHoverEl = null;
1270            lastHoverText = '';
1271            sessionStorage.setItem('voiceOverEnabled', 'true');
1272        }
1273
1274        function isHomePage() {
1275            if (document.body.classList.contains('homepage')) return true;
1276            const strip = (p) => (p || '').toLowerCase().replace(/\/+$/, '');
1277            const path = strip(window.location.pathname);
1278            if (path === '' || path.endsWith('/home')) return true;
1279            // The logo links to the home page of the current language (localized
1280            // slug, e.g. /fr/accueil). The language base itself (e.g. /fr/) serves
1281            // the same page, so both count as home.
1282            const logoLink = document.querySelector('#logo a[href]');
1283            if (logoLink) {
1284                try {
1285                    const home = strip(new URL(logoLink.getAttribute('href'), window.location.origin).pathname);
1286                    if (home && (path === home || path === home.slice(0, home.lastIndexOf('/')))) return true;
1287                } catch (e) { /* malformed href — fall through */ }
1288            }
1289            return false;
1290        }
1291
1292        function promptVoiceOverOnHome() {
1293            if (voicePrompted) return;
1294            if (!isHomePage()) return;
1295            if (sessionStorage.getItem('voiceOverPrompted') === 'true') return;
1296
1297            voicePrompted = true;
1298            sessionStorage.setItem('voiceOverPrompted', 'true');
1299
1300            const promptText = speechText('Press Enter to activate voice over.');
1301
1302            let promptSpoken = false;
1303            let ttsFallbackDone = false;
1304            const promptAudioUrl = getPromptAudioUrl();
1305
1306            function speakViaTts() {
1307                if (ttsFallbackDone) return;
1308                ttsFallbackDone = true;
1309                const utterance = buildUtterance(promptText);
1310                utterance.onstart = function () { promptSpoken = true; };
1311                window.speechSynthesis.cancel();
1312                window.speechSynthesis.speak(utterance);
1313                window.speechSynthesis.resume();
1314            }
1315
1316            function speakPrompt() {
1317                ttsFallbackDone = false;
1318                if (promptAudioUrl) {
1319                    const audio = new Audio(promptAudioUrl);
1320                    audio.addEventListener('error', function () {
1321                        // Recording missing/unreadable — use the synthesis voice.
1322                        if (!promptSpoken) speakViaTts();
1323                    });
1324                    const playing = audio.play();
1325                    if (playing && playing.then) {
1326                        playing.then(function () { promptSpoken = true; })
1327                            .catch(function () { /* autoplay blocked — first gesture retries */ });
1328                    }
1329                    return;
1330                }
1331                speakViaTts();
1332            }
1333            speakPrompt();
1334
1335            function cleanup() {
1336                clearTimeout(timeoutId);
1337                document.removeEventListener('keydown', onKeydownPrompt, true);
1338                document.removeEventListener('mousedown', onFirstGesture, true);
1339                document.removeEventListener('touchstart', onFirstGesture, true);
1340            }
1341
1342            const timeoutId = setTimeout(cleanup, 15000);
1343
1344            // The first gesture grants speech permission — retry the spoken
1345            // prompt if the initial attempt was blocked.
1346            function onFirstGesture() {
1347                document.removeEventListener('mousedown', onFirstGesture, true);
1348                document.removeEventListener('touchstart', onFirstGesture, true);
1349                if (!promptSpoken && !window.speechSynthesis.speaking) speakPrompt();
1350            }
1351
1352            function onKeydownPrompt(e) {
1353                if (e.key === 'Enter') {
1354                    cleanup();
1355                    const button = document.querySelector('[data-accessibility="voiceOver"]');
1356                    if (button) {
1357                        toggleVoiceOver(button);
1358                    }
1359                    return;
1360                }
1361                onFirstGesture();
1362            }
1363
1364            document.addEventListener('keydown', onKeydownPrompt, true);
1365            document.addEventListener('mousedown', onFirstGesture, true);
1366            document.addEventListener('touchstart', onFirstGesture, true);
1367        }
1368 
1369 
1370 
1371 
1372
1373(function() {
1374  /**
1375   * Translations
1376   */
1377  var pageLang = (document.documentElement.lang || 'en-US').substring(0, 2);
1378  var asbTranslations = {
1379    "en": {
1380      "voiceShortcuts": "Voice Shortcuts",
1381      "contrast": "High Contrast",
1382      "dark": "Dark Mode",
1383      "voiceOver": "Voice Over",
1384      "voiceSpeed": "Speed 1.0x",
1385      "voiceVerbosity": "Verbosity",
1386      "voicePunctuation": "Punctuation",
1387      "markerLine": "Marker Line",
1388      "readingLine": "Reading Line",
1389      "reset": "Reset",
1390      "menu": "Accessibility"
1391    },
1392    "fr": {
1393      "voiceShortcuts": "Raccourcis vocaux",
1394      "contrast": "Contraste élevé",
1395      "dark": "Mode sombre",
1396      "voiceOver": "Synthèse vocale",
1397      "voiceSpeed": "Vitesse 1.0x",
1398      "voiceVerbosity": "Verbosité",
1399      "voicePunctuation": "Ponctuation",
1400      "markerLine": "Ligne de marquage",
1401      "readingLine": "Ligne de lecture",
1402      "reset": "Réinitialiser",
1403      "menu": "Accessibilité"
1404    },
1405    "rw": {
1406      "voiceShortcuts": "Voice Shortcuts",
1407      "contrast": "High Contrast",
1408      "dark": "Dark Mode",
1409      "voiceOver": "Voice Over",
1410      "voiceSpeed": "Speed 1.0x",
1411      "voiceVerbosity": "Verbosity",
1412      "voicePunctuation": "Punctuation",
1413      "markerLine": "Marker Line",
1414      "readingLine": "Reading Line",
1415      "reset": "Reset",
1416      "menu": "Accessibility"
1417    }
1418  };
1419  var langMap = asbTranslations[pageLang] || asbTranslations["en"];
1420  function asbT(key) {
1421    return langMap[key] || key;
1422  }
1423
1424  /**
1425   * Content
1426   */
1427
1428   // Tecla que será usada para complementar o atalho do teclado.
1429  const accessKey = 4;
1430
1431  // Definições dos botões
1432  const btns = {
1433    btnVoiceShortcuts: {
1434      active: true,
1435      dataAccessibility: "voiceShortcuts",
1436      class: "setAccessibility",
1437      icon: "FontAwesome",
1438      iconClass: ["fas", "fa-keyboard"],
1439      text: "Voice Shortcuts",
1440    },
1441    btnHighContrast: {
1442      active: true,
1443      dataAccessibility: "contrast",
1444      class: "setAccessibility",
1445      icon: "FontAwesome",
1446      iconClass: ["fas", "fa-adjust"],
1447      text: "High Contrast",
1448    },
1449    btnDarkMode: { 
1450      active: true,
1451      dataAccessibility: "dark",
1452      class: "setAccessibility",
1453      icon: "FontAwesome",
1454      iconClass: ["fas", "fa-moon"],
1455      text: "Dark Mode",
1456    },
1457//     btnReadScreanW: {
1458//       active: true,
1459//       dataAccessibility: "C:\Users\Hp-PC\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Accessibility\%windir%\system32\narrator.exe",
1460//       class: "setAccessibility",
1461//  icon: "FontAwesome",
1462//       iconClass: ["fab", "fa-windows"],
1463//       text: "Windows Voice Over",
1464
1465//     },
1466    // btnReadScreenM: {
1467    //   active: true,
1468    //   id: "voiceOverButton",
1469    //   dataAccessibility: "javascript:runProgram()",
1470    //   class: "setAccessibility",
1471    //   icon: "FontAwesome",
1472    //   iconClass: ["fab", "fa-apple"],
1473    //   text: "Voice Over",
1474     
1475      
1476      
1477      
1478    // },
1479    btnReadScreenM: {
1480      active: true,
1481      dataAccessibility: "voiceOver",
1482      class: "setAccessibility",
1483      icon: "FontAwesome",
1484      iconClass: ["fas", "fa-volume-up"],
1485      text: "Voice Over",
1486    },
1487    btnVoiceSpeed: {
1488      active: true,
1489      dataAccessibility: "voiceSpeed",
1490      class: "setAccessibility",
1491      icon: "FontAwesome",
1492      iconClass: ["fas", "fa-tachometer-alt"],
1493      text: "Speed 1.0x",
1494    },
1495    btnVoiceVerbosity: {
1496      active: true,
1497      dataAccessibility: "voiceVerbosity",
1498      class: "setAccessibility",
1499      icon: "FontAwesome",
1500      iconClass: ["fas", "fa-comment-dots"],
1501      text: "Verbosity",
1502    },
1503    btnVoicePunctuation: {
1504      active: true,
1505      dataAccessibility: "voicePunctuation",
1506      class: "setAccessibility",
1507      icon: "FontAwesome",
1508      iconClass: ["fas", "fa-ellipsis-h"],
1509      text: "Punctuation",
1510    },
1511    
1512    
1513    
1514    
1515    
1516    // btnDecFont: {
1517    //   active: true,
1518    //   dataAccessibility: "decFont",
1519    //   class: "setAccessibility",
1520    //   icon: "A-",
1521    //   iconClass: "",
1522    //   text: "Decrease",
1523    // },
1524    btnMarkerLine: {
1525      active: true,
1526      dataAccessibility: "markerLine",
1527      class: "setAccessibility",
1528      icon: "FontAwesome",
1529      iconClass: ["fas", "fa-ruler-horizontal"],
1530      text: "Marker Line",
1531    },
1532    btnReadingLine: {
1533      active: true,
1534      dataAccessibility: "readingLine",
1535      class: "setAccessibility",
1536      icon: "FontAwesome",
1537      iconClass: ["fas", "fa-ruler-horizontal"],
1538      text: "Reading Line",
1539    },
1540    btnReset: {
1541      active: true,
1542      dataAccessibility: "reset",
1543      class: "setAccessibility",
1544      icon: "FontAwesome",
1545      iconClass: ["fas", "fa-redo-alt"],
1546      text: "Reset",
1547    },
1548  }
1549
1550  /**
1551   * Creating the bar
1552   */
1553
1554  const accessibilityBar = document.createElement("div");
1555  accessibilityBar.id = "accessibilityBar";
1556  document.body.insertBefore(accessibilityBar, document.body.firstChild);
1557
1558  // Container for action buttons (scrollable)
1559  const accessibilityList = document.createElement("div");
1560  accessibilityList.id = "accessibilityList";
1561  accessibilityBar.appendChild(accessibilityList);
1562
1563  /**
1564   * Creating main button
1565   */
1566  let btnAccessibilityBar;
1567
1568  function createMainButton() {
1569    btnAccessibilityBar = document.createElement("button");
1570    btnAccessibilityBar.id = "universalAccessBtn";
1571    btnAccessibilityBar.type = "button";
1572    btnAccessibilityBar.accessKey = accessKey;
1573    accessibilityBar.appendChild(btnAccessibilityBar);
1574
1575    const icon = document.createElement("i");
1576    btnAccessibilityBar.appendChild(icon);
1577    icon.classList.add("fas", "fa-universal-access");
1578
1579    const spanText = document.createElement("span");
1580    const spanTextNode = document.createTextNode(asbT("menu"));
1581    spanText.appendChild(spanTextNode);
1582    btnAccessibilityBar.appendChild(spanText);
1583  }
1584  createMainButton();
1585
1586  /**
1587   * Creating anothers button
1588   */
1589
1590  function createButtons(el) {
1591    const button = document.createElement("button");
1592    button.type = "button";
1593    button.classList.add(el.class);
1594    button.setAttribute('data-accessibility', el.dataAccessibility);
1595    if (el.dataAccessibility === "voiceSpeed" ||
1596        el.dataAccessibility === "voiceVerbosity" ||
1597        el.dataAccessibility === "voicePunctuation") {
1598      button.setAttribute("aria-live", "polite");
1599    }
1600    accessibilityList.appendChild(button);
1601
1602    const wrapIcon = document.createElement("strong");
1603    button.appendChild(wrapIcon);
1604
1605    if (el.icon === "FontAwesome") {
1606      const icon = document.createElement("i");
1607      wrapIcon.appendChild(icon);
1608      icon.classList.add(...el.iconClass);
1609    } else {
1610      const textIcon = document.createTextNode(el.icon);
1611      wrapIcon.appendChild(textIcon);
1612    }
1613
1614    const label = document.createElement("span");
1615    label.className = "accessibility-label";
1616    label.textContent = asbT(el.dataAccessibility) || el.text;
1617    if (el.dataAccessibility === "voiceSpeed") {
1618      label.setAttribute("data-voice-rate-indicator", "true");
1619    }
1620    if (el.dataAccessibility === "voiceVerbosity") {
1621      label.setAttribute("data-voice-verbosity-indicator", "true");
1622    }
1623    if (el.dataAccessibility === "voicePunctuation") {
1624      label.setAttribute("data-voice-punctuation-indicator", "true");
1625    }
1626    button.appendChild(label);
1627  }
1628  Object.keys(btns).forEach(function (item) {
1629    if(btns[item].active){
1630      createButtons(btns[item]);
1631    }
1632   });
1633
1634
1635  const html = document.documentElement; //<html> for font-size settings
1636  const body = document.body; //<body> for the adjusts classes
1637  const btnAccessibility = document.querySelectorAll("[data-accessibility]"); // Getting settings buttons
1638
1639  if (btnAccessibilityBar) {
1640    setTimeout(function() {
1641      btnAccessibilityBar.classList.add("collapsed");
1642    }, 2000);
1643  }
1644
1645  /**
1646   * ReadingLine
1647   */
1648
1649  const readingLine = document.createElement("div");
1650  readingLine.id = "readingLine";
1651  document.body.insertBefore(readingLine, document.body.firstChild);
1652
1653  html.addEventListener("mousemove", function(e) {
1654    if (body.classList.contains("accessibility_readingLine")) {
1655      let linePositionY = e.pageY - 20;
1656      // console.log(linePositionY);
1657      const elReadingLine = document.querySelector("#readingLine"); // Toggle button
1658      elReadingLine.style.top = `${linePositionY}px`;
1659    }
1660  });
1661
1662  /**
1663   * MarkerLine
1664   */
1665
1666  const markerLine = document.createElement("div");
1667  markerLine.id = "markerLine";
1668  document.body.insertBefore(markerLine, document.body.firstChild);
1669
1670  html.addEventListener("mousemove", function(e) {
1671    if (body.classList.contains("accessibility_markerLine")) {
1672      let linePositionY = e.pageY - 20;
1673      // console.log(linePositionY);
1674      const elmarkerLine = document.querySelector("#markerLine"); // Toggle button
1675      elmarkerLine.style.top = `${linePositionY}px`;
1676    }
1677  });
1678
1679  /*
1680=== === === === === === === === === === === === === === === === === ===
1681=== === === === === === === === openBar === === === === === === === ===
1682=== === === === === === === === === === === === === === === === === ===
1683*/
1684
1685btnAccessibilityBar.addEventListener("click", () => {
1686    accessibilityBar.classList.toggle("active");
1687    
1688    // Change icon between person and X
1689    const icon = btnAccessibilityBar.querySelector("i");
1690    if (accessibilityBar.classList.contains("active")) {
1691        icon.classList.remove("fa-universal-access");
1692icon.classList.add("fa-times", "fa-light");
1693    } else {
1694        icon.classList.remove("fa-times");
1695        icon.classList.add("fa-universal-access");
1696    }
1697});
1698  /*
1699=== === === === === === === === === === === === === === === === === ===
1700=== === === === === ===  toggleAccessibilities  === === === === === ===
1701=== === === === === === === === === === === === === === === === === ===
1702*/
1703
1704  function toggleAccessibilities(action) {
1705    switch (action) {
1706      case "contrast":
1707        window.toggleContrast();
1708        break;
1709      case "dark":
1710        window.toggleDark();
1711        break;
1712      case "incFont":
1713        window.toggleFontSize(action);
1714        break;
1715      case "oriFont":
1716        window.toggleFontSize(action);
1717        break;
1718      case "decFont":
1719        window.toggleFontSize(action);
1720        break;
1721      case "readingLine":
1722        body.classList.toggle("accessibility_readingLine");
1723        break;
1724      case "markerLine":
1725        body.classList.toggle("accessibility_markerLine");
1726        break;
1727      case "reset":
1728        Dark.currentState === true ? Dark.setState(false) : null;
1729        Contrast.currentState === true ? Contrast.setState(false) : null;
1730        window.toggleFontSize("oriFont");
1731        body.classList.remove("accessibility_readingLine");
1732        body.classList.remove("accessibility_markerLine");
1733        break;
1734      case "voiceSpeed":
1735        if (voiceOverEnabled) {
1736          const preview = buildUtterance(`${speechText('Speed')} ${voiceRate.toFixed(1)}x`);
1737          window.speechSynthesis.cancel();
1738          window.speechSynthesis.speak(preview);
1739          window.speechSynthesis.resume();
1740        }
1741        break;
1742      case "voiceVerbosity":
1743        if (voiceVerbosity === "verbose") voiceVerbosity = "compact";
1744        else if (voiceVerbosity === "compact") voiceVerbosity = "minimal";
1745        else voiceVerbosity = "verbose";
1746        sessionStorage.setItem(voiceVerbosityStorageKey, voiceVerbosity);
1747        updateVoiceVerbosityUI();
1748        if (isSpeaking) {
1749          restartSpeechIfActive();
1750        }
1751        break;
1752      case "voicePunctuation":
1753        voicePunctuation = (voicePunctuation === "all") ? "none" : "all";
1754        sessionStorage.setItem(voicePunctuationStorageKey, voicePunctuation);
1755        updateVoicePunctuationUI();
1756        if (isSpeaking) {
1757          restartSpeechIfActive();
1758        } else if (voiceOverEnabled) {
1759          const sample = speechText("Test, period. Question? Exclamation! Colon: semicolon; dash - slash /.");
1760          window.speechSynthesis.cancel();
1761          window.speechSynthesis.speak(buildUtterance(sample));
1762          window.speechSynthesis.resume();
1763        }
1764        break;
1765      case "voiceShortcuts":
1766        if (!voiceOverEnabled) {
1767          toggleVoiceOver(button);
1768        }
1769        const shortcutsText = [
1770          "Shortcuts.",
1771          "Quick Nav toggle: press Left Arrow and Right Arrow together.",
1772          "When Quick Nav is on: H for next heading, B for next button, L for next link, F for next form field.",
1773          "Arrow Up increases voice speed. Arrow Down decreases voice speed.",
1774          "Tab moves focus to next control."
1775        ].map(speechText).join(" ");
1776        window.speechSynthesis.cancel();
1777        window.speechSynthesis.speak(buildUtterance(shortcutsText));
1778        window.speechSynthesis.resume();
1779        break;
1780      default:
1781        break;
1782    }
1783    accessibilityBar.classList.toggle("active");
1784  }
1785
1786  updateVoiceRateUI();
1787  updateVoiceVerbosityUI();
1788  updateVoicePunctuationUI();
1789  promptVoiceOverOnHome();
1790
1791  if (voiceAutoResume) {
1792    const button = document.querySelector('[data-accessibility="voiceOver"]');
1793    if (button && !voiceOverEnabled) {
1794      // On the PDF reader page, wait until PDF.js has extracted the text into
1795      // the DOM — otherwise innerText only contains the title and breadcrumb.
1796      const pdfReader = document.body.getAttribute('data-pdf-reader');
1797      if (pdfReader === 'loading') {
1798        window.addEventListener('asb:pdf-text-ready', function () {
1799          if (!voiceOverEnabled) toggleVoiceOver(button);
1800        }, { once: true });
1801      } else {
1802        toggleVoiceOver(button);
1803      }
1804    }
1805  }
1806
1807btnAccessibility.forEach(button =>
1808    button.addEventListener("click", () => {
1809      const action = button.dataset.accessibility;
1810
1811      if (action === "voiceOver") {
1812        toggleVoiceOver(button);
1813      } else {
1814        toggleAccessibilities(action);
1815      }
1816    })
1817  );
1818
1819  // Keep aria-expanded in sync for mobile menu
1820  const menuTrigger = document.getElementById('primary-menu-trigger');
1821  if (menuTrigger) {
1822    const updateMenuAria = () => {
1823      // functions.js toggles body.primary-menu-open when the responsive menu is opened.
1824      // Rely on that single source of truth to avoid "always expanded" false positives.
1825      const isOpen = document.body.classList.contains('primary-menu-open');
1826      menuTrigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
1827      menuTrigger.setAttribute('aria-label', isOpen ? 'Collapse main menu' : 'Open main menu');
1828    };
1829
1830    updateMenuAria();
1831    menuTrigger.addEventListener('click', () => {
1832      setTimeout(updateMenuAria, 0);
1833    });
1834    menuTrigger.addEventListener('keydown', (e) => {
1835      if (e.key === 'Enter' || e.key === ' ') {
1836        e.preventDefault();
1837        menuTrigger.click();
1838      }
1839    });
1840  }
1841
1842  /*
1843=== === === === === === === === === === === === === === === === === ===
1844=== === === === === === ===  FontSize   === === === === === === === ===
1845=== === === === === === === === === === === === === === === === === ===
1846*/
1847
1848  const htmlFontSize = parseFloat(
1849    getComputedStyle(document.documentElement).getPropertyValue("font-size")
1850  );
1851  let FontSize = {
1852    storage: "fontSizeState",
1853    cssClass: "fontSize",
1854    currentState: null,
1855    check: checkFontSize,
1856    getState: getFontSizeState,
1857    setState: setFontSizeState,
1858    toggle: toggleFontSize,
1859    updateView: updateViewFontSize
1860  };
1861
1862  window.toggleFontSize = function(action) {
1863    FontSize.toggle(action);
1864  };
1865
1866  FontSize.check();
1867
1868  function checkFontSize() {
1869    this.updateView();
1870  }
1871
1872  function getFontSizeState() {
1873    return sessionStorage.getItem(this.storage)
1874      ? sessionStorage.getItem(this.storage)
1875      : 100;
1876  }
1877
1878  function setFontSizeState(state) {
1879    sessionStorage.setItem(this.storage, "" + state);
1880    this.currentState = state;
1881    this.updateView();
1882  }
1883
1884  function updateViewFontSize() {
1885    if (this.currentState === null) this.currentState = this.get
1885State();
1886
1887    this.currentState
1888      ? (html.style.fontSize = (this.currentState / 100) * htmlFontSize + "px")
1889      : "";
1890
1891    this.currentState
1892      ? body.classList.add(this.cssClass + this.currentState)
1893      : "";
1894  }
1895
1896  function toggleFontSize(action) {
1897    switch (action) {
1898      case "incFont":
1899        if (parseFloat(this.currentState) < 400) {
1900          body.classList.remove(this.cssClass + this.currentState);
1901          this.setState(parseFloat(this.currentState) + 20);
1902        } else {
1903          alert("Limite 400px");
1904        }
1905        break;
1906      case "oriFont":
1907        body.classList.remove(this.cssClass + this.currentState);
1908        this.setState(100);
1909        break;
1910      case "decFont":
1911        if (parseFloat(this.currentState) > 40) {
1912          body.classList.remove(this.cssClass + this.currentState);
1913          this.setState(parseFloat(this.currentState) - 20);
1914        } else {
1915          alert("Limite 400px");
1916        }
1917        break;
1918      default:
1919        break;
1920    }
1921  }
1922
1923  /*
1924=== === === === === === === === === === === === === === === === === ===
1925=== === === === === ===  HighConstrast  === === === === === === === ===
1926=== === === === === === === === === === === === === === === === === ===
1927*/
1928  let Contrast = {
1929    storage: "contrastState",
1930    cssClass: "contrast",
1931    currentState: null,
1932    check: checkContrast,
1933    getState: getContrastState,
1934    setState: setContrastState,
1935    toggle: toggleContrast,
1936    updateView: updateViewContrast
1937  };
1938
1939  window.toggleContrast = function() {
1940    Contrast.toggle();
1941  };
1942
1943  Contrast.check();
1944
1945  function checkContrast() {
1946    this.updateView();
1947  }
1948
1949  function getContrastState() {
1950    return sessionStorage.getItem(this.storage) === "true";
1951  }
1952
1953  function setContrastState(state) {
1954    sessionStorage.setItem(this.storage, "" + state);
1955    this.currentState = state;
1956    this.updateView();
1957  }
1958
1959  function updateViewContrast() {
1960    if (this.currentState === null) this.currentState = this.getState();
1961
1962    this.currentState
1963      ? body.classList.add(this.cssClass)
1964      : body.classList.remove(this.cssClass);
1965  }
1966
1967  function toggleContrast() {
1968    this.setState(!this.currentState);
1969    Dark.currentState === true ? Dark.setState(false) : null;
1970  }
1971
1972  /*
1973=== === === === === === === === === === === === === === === === === ===
1974=== === === === === === ===   DarkMode  === === === === === === === ===
1975=== === === === === === === === === === === === === === === === === ===
1976*/
1977  let Dark = {
1978    storage: "darkState",
1979    cssClass: "darkmode",
1980    currentState: null,
1981    check: checkDark,
1982    getState: getDarkState,
1983    setState: setDarkState,
1984    toggle: toggleDark,
1985    updateView: updateViewDark
1986  };
1987
1988  window.toggleDark = function() {
1989    Dark.toggle();
1990  };
1991
1992  Dark.check();
1993
1994  function checkDark() {
1995    this.updateView();
1996  }
1997
1998  function getDarkState() {
1999    return sessionStorage.getItem(this.storage) === "true";
2000  }
2001
2002  function setDarkState(state) {
2003    sessionStorage.setItem(this.storage, "" + state);
2004    this.currentState = state;
2005    this.updateView();
2006  }
2007
2008  function updateViewDark() {
2009    if (this.currentState === null) this.currentState = this.getState();
2010
2011    this.currentState
2012      ? body.classList.add(this.cssClass)
2013      : body.classList.remove(this.cssClass);
2014  }
2015
2016  function toggleDark() {
2017    this.setState(!this.currentState);
2018    Contrast.currentState === true ? Contrast.setState(false) : null;
2019  }
2020  
2021})();
2022
2023// Keep accessibility bar from overlapping the footer when scrolling
2024(function () {
2025  var bar    = null;
2026  var footer = null;
2027
2028  function clampBar() {
2029    if (!bar)    bar    = document.getElementById('accessibilityBar');
2030    if (!footer) footer = document.getElementById('footerr');
2031    if (!bar || !footer) return;
2032
2033    var barH = bar.offsetHeight;
2034    var fTop = footer.getBoundingClientRect().top;
2035    var vh   = window.innerHeight;
2036    var vw   = window.innerWidth;
2037
2038    // Default centred position — matches the CSS breakpoints
2039    var defaultTopPx = vh * (vw <= 576 ? 0.40 : vw <= 992 ? 0.45 : 0.50);
2040
2041    // With top:T and translateY(-50%):
2042    //   visual bottom = T + barH/2
2043    // We need: T + barH/2 <= fTop - 10  →  T <= fTop - barH/2 - 10
2044    var maxTopPx = fTop - barH * 0.5 - 10;
2045
2046    // Use whichever is higher on screen (smaller value)
2047    var newTop = Math.min(defaultTopPx, maxTopPx);
2048
2049    // Absolute floor: keep at least 30 px of the bar visible at the top of the viewport
2050    // (allows bar to slide almost fully off-screen rather than drop into the footer)
2051    newTop = Math.max(newTop, 30 - barH * 0.5);
2052
2053    bar.style.top = newTop + 'px';
2054  }
2055
2056  window.addEventListener('scroll', clampBar, { passive: true });
2057  window.addEventListener('resize', clampBar);
2058  if (document.readyState === 'loading') {
2059    document.addEventListener('DOMContentLoaded', clampBar);
2060  } else {
2061    clampBar();
2062  }
2063})();
2064
2065// PDF Accessible Reader — route PDF clicks through pdf-reader.php only when voice-over is ON
2066(function () {
2067    // Never intercept on the reader page itself (so "Open Original PDF" always works)
2068    if (window.location.pathname.indexOf('pdf-reader.php') !== -1) return;
2069
2070    function isPdfLink(el) {
2071        if (!el || el.tagName !== 'A') return false;
2072        var href = el.getAttribute('href');
2073        if (!href || href.indexOf('#') === 0 || href.indexOf('javascript:') === 0) return false;
2074        if (href.indexOf('pdf-reader.php') !== -1) return false;
2075        try {
2076            return new URL(href, window.location.href).pathname.toLowerCase().endsWith('.pdf');
2077        } catch (e) {
2078            return href.toLowerCase().indexOf('.pdf') !== -1;
2079        }
2080    }
2081
2082    document.addEventListener('click', function (e) {
2083        var a = e.target.closest ? e.target.closest('a') : null;
2084        if (!isPdfLink(a)) return;
2085
2086        // Let the link behave normally when voice-over is off
2087        if (sessionStorage.getItem('voiceOverEnabled') !== 'true') return;
2088
2089        e.preventDefault();
2090        var href = a.getAttribute('href');
2091        var returnPath = window.location.pathname + window.location.search;
2092        window.location.href = '/pdf-reader.php?file=' + encodeURIComponent(href) +
2093                               '&return=' + encodeURIComponent(returnPath);
2094    }, true);
2095})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.