Accessible PDF Reading in Blazor PDF Viewer
17 Jul 202624 minutes to read
Accessible PDF reading lets users listen to PDF content using speech output. It improves usability for people who prefer audio interaction, need hands‑free review, or rely on screen readers.
This accessible PDF reading feature supports two approaches:
- Microsoft Edge’s Screen Reader
- Windows Speech Synthesis API
The following demo illustrates both supported approaches:
Microsoft Edge’s Screen Reader
Use Microsoft Edge’s built-in Read Aloud to listen to the PDF content.
- Open the sample document in Microsoft Edge.
- Click the Read Aloud icon in the Edge toolbar or press Ctrl + Shift + U to start reading the document aloud.
- To start from a specific page, navigate to that page and click anywhere; reading begins from the first readable text at that location.
- To read only selected text, select the sentence or portion of text you want, then use Read Aloud to read just that selection.
The following steps describe a custom Read Aloud implementation that is triggered when text is selected in the Blazor PDF Viewer.
Step 1: Create and add a JavaScript file to the app and reference it in the head element.
<head>
<script src="accessibility.js" type="text/javascript"></script>
</head>Step 2: Add the following code to the JavaScript accessibility.js file.
const synth = window.speechSynthesis;
let voices = [];
const voicesReady = new Promise((resolve) => {
const tryGet = () => {
const voice = synth.getVoices();
if (voice && voice.length) {
voices = voice;
resolve(voice);
return true;
}
return false;
};
if (tryGet()) return;
const onVoices = () => {
if (tryGet()) {
synth.removeEventListener('voiceschanged', onVoices);
}
};
synth.addEventListener('voiceschanged', onVoices);
// Fallback polling for browsers that don't fire voiceschanged reliably
let tries = 0;
const poll = setInterval(() => {
if (tryGet() || ++tries > 30) {
clearInterval(poll);
voices = synth.getVoices() || [];
synth.removeEventListener('voiceschanged', onVoices);
resolve(voices);
}
}, 100);
});
// iOS/iPadOS Safari requires a user gesture before speech works reliably.
// This one-time unlock speaks a silent utterance on first tap/click/keydown.
let __ttsUnlocked = false;
let __unlockPromise = null;
function ensureTtsUnlocked() {
if (__ttsUnlocked) return Promise.resolve();
if (__unlockPromise) return __unlockPromise;
__unlockPromise = new Promise((resolve) => {
const cleanup = () => {
['click', 'touchstart', 'keydown'].forEach(evt => document.removeEventListener(evt, onEvent, true));
};
const onEvent = () => {
try {
const u = new SpeechSynthesisUtterance(''); // silent token
u.volume = 0;
u.rate = 1;
u.onend = () => {
__ttsUnlocked = true;
cleanup();
resolve();
};
// Queue in a macrotask to avoid race with gesture handling
setTimeout(() => synth.speak(u), 0);
} catch (_) {
__ttsUnlocked = true;
cleanup();
resolve();
}
};
['click', 'touchstart', 'keydown'].forEach(evt => document.addEventListener(evt, onEvent, true));
});
return __unlockPromise;
}
function populateVoiceList() {
voices = synth.getVoices().sort(function (a, b) {
const aname = a.name.toUpperCase();
const bname = b.name.toUpperCase();
if (aname < bname) {
return -1;
} else if (aname == bname) {
return 0;
} else {
return +1;
}
});
}
async function speakFromControls(input) {
await voicesReady;
const t = (typeof input === 'string' ? input.trim() : (input?.value || '').trim());
if (!t) return;
// Cancel any current speech to avoid overlaps/refresh quirks
if (synth.speaking) {
synth.cancel();
}
const utterThis = new SpeechSynthesisUtterance(t);
utterThis.onend = function () {
console.log("SpeechSynthesisUtterance.onend");
};
const available = speechSynthesis.getVoices();
let voice = null;
voice = available.find(v => v.default) || available[0];
if (voice) {
utterThis.voice = voice;
if (voice.lang) utterThis.lang = voice.lang; // Safari iOS respects lang better
}
utterThis.pitch = 1;
utterThis.rate = 1;
// Safari sometimes needs a microtask delay after cancel before speak
setTimeout(() => synth.speak(utterThis), 0);
}
async function initUi() {
await voicesReady;
// Populate voices now that controls exist
populateVoiceList();
if (speechSynthesis.onvoiceschanged !== undefined) {
speechSynthesis.onvoiceschanged = populateVoiceList;
}
return true;
}
// Initialize when DOM is ready; also handle Blazor re-renders
document.addEventListener('DOMContentLoaded', async () => {
// Set up iOS unlock listeners early
ensureTtsUnlocked();
if (await initUi()) return;
const obs = new MutationObserver(async () => {
if (await initUi()) obs.disconnect();
});
obs.observe(document.body, { childList: true, subtree: true });
});
// Expose a helper to manually unlock from .NET or UI (tap/click)
window.unlockTtsForIOS = () => ensureTtsUnlocked();
// Initialize PDF accessibility features and observe page changes
function initPdfAccessibility() {
const viewerInfo = getViewerInfo();
if (!viewerInfo) return;
viewerInfo.container.querySelectorAll('.e-pv-page-div').forEach(wirePage);
const mutationObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType !== 1) return;
if (node.matches && node.matches('.e-pv-page-div')) {
wirePage(node);
} else if (node.querySelectorAll) {
node.querySelectorAll('.e-pv-page-div').forEach(wirePage);
}
});
});
});
mutationObserver.observe(viewerInfo.container, { childList: true, subtree: true });
}
// Get viewer container and ID
function getViewerInfo() {
const container = document.querySelector('.e-pv-viewer-container');
if (!container) return null;
return { container, viewerId: container.id.replace('_viewerContainer', '') };
}
// Insert hidden SR node for screen reader - Microsoft Reader
function insertSrNode(div) {
const pageNumber = (div.id.match(/pageDiv_(\d+)/)[1]);
const srId = `pdf_page_${pageNumber}_sr`;
let sr = document.getElementById(srId);
if (!sr) {
sr = Object.assign(document.createElement('div'), { id: srId, className: 'e-pv-radio-btn', tabIndex: -1 });
div.appendChild(sr);
}
return sr;
}
// Select SR text for screen reader - Microsoft Edge’s Screen Reader
function selectSrText(div) {
const sr = insertSrNode(div);
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(sr);
selection.removeAllRanges();
selection.addRange(range);
}
// Move caret to first visible text node - Microsoft Edge’s Screen Reader
function collapseCaretToVisibleText(div) {
const textLayer = div.querySelector('.e-pv-text-layer');
if (textLayer) {
const selection = window.getSelection();
const range = document.createRange();
range.setStart(textLayer.firstChild, 0);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
}
// Focus page for accessibility - Microsoft Edge’s Screen Reader
function focusPageDiv(div) {
if (!div) return;
const textLayer = div.querySelector('.e-pv-text-layer');
if (!textLayer || !textLayer.textContent.trim()) {
requestAnimationFrame(() => focusPageDiv(div));
return;
}
const sr = insertSrNode(div);
sr.textContent = textLayer.textContent;
selectSrText(div);
collapseCaretToVisibleText(div);
}
// Wire accessibility handlers to page div - Microsoft Edge’s Screen Reader
function wirePage(div) {
if (!div || div.hasAttribute('data-a11y-init')) return;
div.addEventListener('mousedown', () => focusPageDiv(div));
div.addEventListener('click', () => focusPageDiv(div));
div.setAttribute('data-a11y-init', 'true');
}
// Read the selected text aloud - Microsoft Edge’s Screen Reader
function readAloudText(text) {
window.speechSynthesis.cancel();
speakFromControls(text);
}
// Cancel speech and remove highlights - Microsoft Edge’s Screen Reader
function cancelReading() {
if (window.speechSynthesis?.speaking) {
window.speechSynthesis.cancel();
}
}Step 3: Add the following code to the Home.razor file.
@page "/"
@inject IJSRuntime JSRuntime
<!-- PDF Viewer component with reference and document path -->
<SfPdfViewer2 @ref="PdfViewer" DocumentPath="@DocumentPath" Height="100%" Width="100%">
<!-- Event bindings for document load/unload and text selection -->
<PdfViewerEvents DocumentLoaded="OnDocumentLoaded" DocumentUnloaded="OnDocumentUnloaded"
OnTextSelectionEnd="HandleTextSelectionEnd" />
</SfPdfViewer2>
@code
{
// Reference to the PDF Viewer component
private SfPdfViewer2? PdfViewer;
// Path to the PDF document to be loaded in the viewer
private string DocumentPath = "https://cdn.syncfusion.com/content/pdf/programmatical-annotations.pdf";
// Triggered when the document is unloaded from the viewer
private async Task OnDocumentUnloaded()
{
// Calls a JavaScript function to cancel any ongoing reading process
await JSRuntime.InvokeVoidAsync("cancelReading");
}
// Triggered when the document is loaded in the viewer
private async Task OnDocumentLoaded()
{
// Calls a JavaScript function to initialize PDF accessibility features
await JSRuntime.InvokeVoidAsync("initPdfAccessibility");
}
// Triggered when text selection ends in the PDF Viewer
private async Task HandleTextSelectionEnd(TextSelectionEndEventArgs args)
{
if (!string.IsNullOrEmpty(args.TextContent))
{
await JSRuntime.InvokeVoidAsync("readAloudText", args.TextContent);
}
}
}
Windows Speech Synthesis API
Use the browser’s Windows Speech Synthesis API (speechSynthesis) to implement in-app Read Aloud functionality with custom controls. This approach:
- Provides playback controls, including Previous, Next, and Pause/Resume.
- Reads content line by line and can continue across multiple pages.
- Reads only the selected text when a selection is made.
- Works on modern browsers and platforms that support the Speech Synthesis API.
Step 1: Create and add a JavaScript file to the app and reference it in the head element.
<head>
<script src="accessibility.js" type="text/javascript"></script>
</head>Step 2: Add the following code to the JavaScript accessibility.js file.
const synth = window.speechSynthesis;
let voices = [];
const voicesReady = new Promise((resolve) => {
const tryGet = () => {
const voice = synth.getVoices();
if (voice && voice.length) {
voices = voice;
resolve(voice);
return true;
}
return false;
};
if (tryGet()) return;
const onVoices = () => {
if (tryGet()) {
synth.removeEventListener('voiceschanged', onVoices);
}
};
synth.addEventListener('voiceschanged', onVoices);
// Fallback polling for browsers that don't fire voiceschanged reliably
let tries = 0;
const poll = setInterval(() => {
if (tryGet() || ++tries > 30) {
clearInterval(poll);
voices = synth.getVoices() || [];
synth.removeEventListener('voiceschanged', onVoices);
resolve(voices);
}
}, 100);
});
// iOS/iPadOS Safari requires a user gesture before speech works reliably.
// This one-time unlock speaks a silent utterance on first tap/click/keydown.
let __ttsUnlocked = false;
let __unlockPromise = null;
function ensureTtsUnlocked() {
if (__ttsUnlocked) return Promise.resolve();
if (__unlockPromise) return __unlockPromise;
__unlockPromise = new Promise((resolve) => {
const cleanup = () => {
['click', 'touchstart', 'keydown'].forEach(evt => document.removeEventListener(evt, onEvent, true));
};
const onEvent = () => {
try {
const u = new SpeechSynthesisUtterance(''); // silent token
u.volume = 0;
u.rate = 1;
u.onend = () => {
__ttsUnlocked = true;
cleanup();
resolve();
};
// Queue in a macrotask to avoid race with gesture handling
setTimeout(() => synth.speak(u), 0);
} catch (_) {
__ttsUnlocked = true;
cleanup();
resolve();
}
};
['click', 'touchstart', 'keydown'].forEach(evt => document.addEventListener(evt, onEvent, true));
});
return __unlockPromise;
}
function populateVoiceList() {
voices = synth.getVoices().sort(function (a, b) {
const aname = a.name.toUpperCase();
const bname = b.name.toUpperCase();
if (aname < bname) {
return -1;
} else if (aname == bname) {
return 0;
} else {
return +1;
}
});
}
async function speakFromControls(input) {
await voicesReady;
const t = (typeof input === 'string' ? input.trim() : (input?.value || '').trim());
if (!t) return;
// Cancel any current speech to avoid overlaps/refresh quirks
if (synth.speaking) {
synth.cancel();
}
const utterThis = new SpeechSynthesisUtterance(t);
utterThis.onend = function () {
console.log("SpeechSynthesisUtterance.onend");
};
const available = speechSynthesis.getVoices();
let voice = null;
voice = available.find(v => v.default) || available[0];
if (voice) {
utterThis.voice = voice;
if (voice.lang) utterThis.lang = voice.lang; // Safari iOS respects lang better
}
utterThis.pitch = 1;
utterThis.rate = 1;
// Safari sometimes needs a microtask delay after cancel before speak
setTimeout(() => synth.speak(utterThis), 0);
}
async function initUi() {
await voicesReady;
// Populate voices now that controls exist
populateVoiceList();
if (speechSynthesis.onvoiceschanged !== undefined) {
speechSynthesis.onvoiceschanged = populateVoiceList;
}
return true;
}
// Initialize when DOM is ready; also handle Blazor re-renders
document.addEventListener('DOMContentLoaded', async () => {
// Set up iOS unlock listeners early
ensureTtsUnlocked();
if (await initUi()) return;
const obs = new MutationObserver(async () => {
if (await initUi()) obs.disconnect();
});
obs.observe(document.body, { childList: true, subtree: true });
});
// Expose a helper to manually unlock from .NET or UI (tap/click)
window.unlockTtsForIOS = () => ensureTtsUnlocked();
// Register .NET object for interop
function registerDotNetObject(dotNetObj) {
window.myDotNetObj = dotNetObj;
ensureTtsUnlocked();
}
// Read selected text and highlight
function readSelectedText(args, zoomLevel, muteVoice) {
const viewerInfo = getViewerInfo();
window.speechSynthesis.cancel();
clearAllHighlights();
const text = args.textContent;
const bounds = args.textBounds;
bounds.forEach(bound => {
const pageDiv = document.getElementById(`${viewerInfo.viewerId}_pageDiv_${bound.pageIndex - 1}`);
if (pageDiv) createHighlightBox(bound, pageDiv, zoomLevel);
});
if (muteVoice) return;
requestAnimationFrame(() => {
speakFromControls(text);
});
}
// Read a line from page and notify .NET
function readLineFromPage(pageIndex, lineIndex, isPrev, muteVoice) {
window.speechSynthesis.cancel();
clearAllHighlights();
const viewerInfo = getViewerInfo();
if (!viewerInfo) return;
const pageDiv = document.getElementById(`${viewerInfo.viewerId}_pageDiv_${pageIndex}`);
if (!pageDiv) return;
function processLines() {
const linesArray = getLinesFromPage(pageIndex);
if (!linesArray || linesArray.length === 0) return;
let currentLineSpans;
if (lineIndex >= linesArray.length && linesArray.length > 0) {
window.myDotNetObj.invokeMethodAsync('GoNextPage');
return;
}
if (lineIndex === -1) {
window.myDotNetObj.invokeMethodAsync('GoPreviousPage');
return;
}
if (isPrev) {
currentLineSpans = linesArray[linesArray.length - 1];
} else {
currentLineSpans = linesArray[lineIndex];
}
addHighlightBox(currentLineSpans);
if (currentLineSpans && !muteVoice) {
const lineText = currentLineSpans.map(s => s.textContent).join(' ');
requestAnimationFrame(() => {
speakFromControls(lineText);
});
}
}
// If spans already exist, process immediately
if (pageDiv.querySelector('.e-pv-text-layer span')) {
processLines();
} else {
// Observe DOM changes until spans are added
const observer = new MutationObserver(() => {
if (pageDiv.querySelector('.e-pv-text-layer span')) {
observer.disconnect();
processLines();
}
});
observer.observe(pageDiv, { childList: true, subtree: true });
}
}
// Update the current line index value
function updateLineIndex(pageIndex) {
return new Promise(resolve => {
const viewerInfo = getViewerInfo();
if (!viewerInfo) return resolve(0);
const pageDiv = document.getElementById(`${viewerInfo.viewerId}_pageDiv_${pageIndex}`);
if (!pageDiv) return resolve(0);
function checkSpanElement() {
const textLayer = pageDiv.querySelector('.e-pv-text-layer');
if (textLayer && textLayer.querySelector('span')) {
const linesArray = getLinesFromPage(pageIndex);
resolve(linesArray.length);
return true;
}
return false;
}
if (!checkSpanElement()) {
const observer = new MutationObserver(() => {
if (checkSpanElement()) observer.disconnect();
});
observer.observe(pageDiv, { childList: true, subtree: true });
}
});
}
// Group spans into lines
function getLinesFromPage(pageIndex) {
const viewerInfo = getViewerInfo();
if (!viewerInfo) return [];
const pageDiv = document.getElementById(`${viewerInfo.viewerId}_pageDiv_${pageIndex}`);
if (!pageDiv) return [];
const textLayer = pageDiv.querySelector('.e-pv-text-layer');
if (!textLayer) return [];
const spans = Array.from(textLayer.querySelectorAll('span'));
if (spans.length === 0) return [];
const rotationStyle = getComputedStyle(spans[0]).transform;
let rotationAngle = 0;
if (rotationStyle && rotationStyle !== 'none') {
const [a, b] = rotationStyle.split('(')[1].split(')')[0].split(',');
rotationAngle = Math.round(Math.atan2(b, a) * (180 / Math.PI));
if (rotationAngle < 0) rotationAngle += 360;
}
const linesMap = new Map();
spans.forEach(span => {
const rect = span.getBoundingClientRect();
const key = (rotationAngle === 90 || rotationAngle === 270) ? Math.round(rect.left) : Math.round(rect.top);
if (!linesMap.has(key)) linesMap.set(key, []);
linesMap.get(key).push(span);
});
return Array.from(linesMap.values()).map(lineSpans =>
lineSpans.sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
return (rotationAngle === 90 || rotationAngle === 270) ? rectA.top - rectB.top : rectA.left - rectB.left;
})
);
}
// Speak text with optional target voiceUri; on end, invoke callbacks and reset UI
function speakText(text, onEnd) {
if (!text || !text.trim()) text = "Warning. No readable text found.";
const utterance = new SpeechSynthesisUtterance(text);
utterance.onend = onEnd;
speechSynthesis.speak(utterance);
}
// Create highlight box for given bounds
function createHighlightBox(bound, pageDiv, zoomLevel) {
const box = document.createElement('div');
Object.assign(box.style, {
position: 'absolute',
left: `${bound.left * zoomLevel}px`,
top: `${bound.top * zoomLevel}px`,
width: `${bound.width * zoomLevel}px`,
height: `${bound.height * zoomLevel}px`,
border: '2px solid rgba(0, 0, 0, 0.6)',
borderRadius: '4px',
pointerEvents: 'none'
});
box.className = 'highlight-box-selected';
// Store original bounds in data attributes
box.dataset.left = bound.left;
box.dataset.top = bound.top;
box.dataset.width = bound.width;
box.dataset.height = bound.height;
pageDiv.appendChild(box);
}
// Highlight spans for a line
function addHighlightBox(currentLineSpans) {
if (!currentLineSpans || currentLineSpans.length === 0) return;
scrollIntoViewIfNeeded(currentLineSpans[0]);
const rects = currentLineSpans.map(s => s.getBoundingClientRect());
const minLeft = Math.min(...rects.map(r => r.left));
const maxRight = Math.max(...rects.map(r => r.right));
const minTop = Math.min(...rects.map(r => r.top));
const maxBottom = Math.max(...rects.map(r => r.bottom));
const pageDiv = currentLineSpans[0].closest('.e-pv-page-div');
const pageRect = pageDiv.getBoundingClientRect();
const highlightBox = document.createElement('div');
Object.assign(highlightBox.style, {
position: 'absolute',
left: `${minLeft - pageRect.left}px`,
top: `${minTop - pageRect.top}px`,
width: `${maxRight - minLeft}px`,
height: `${maxBottom - minTop}px`,
border: '2px solid rgba(0, 0, 0, 0.6)',
borderRadius: '4px',
pointerEvents: 'none'
});
highlightBox.className = 'highlight-box';
pageDiv.appendChild(highlightBox);
}
// Update highlight dynamically while reading
function updateHighlightBox(pageIndex, lineIndex, zoomLevel) {
if (!window.speechSynthesis?.speaking) return;
// Check if line highlight exists
const lineHighlights = document.querySelectorAll('.highlight-box');
const selectedHighlights = document.querySelectorAll('.highlight-box-selected');
if (lineHighlights.length > 0) {
// Update line highlight without removing
clearLineHighlight();
function tryUpdate() {
const linesArray = getLinesFromPage(pageIndex);
if (!linesArray || !linesArray[lineIndex]) {
setTimeout(tryUpdate, 0);
return;
}
addHighlightBox(linesArray[lineIndex]);
}
tryUpdate();
} else if (selectedHighlights.length > 0) {
// Update selected-text highlights based on zoom
selectedHighlights.forEach(box => {
const left = parseFloat(box.dataset.left);
const top = parseFloat(box.dataset.top);
const width = parseFloat(box.dataset.width);
const height = parseFloat(box.dataset.height);
Object.assign(box.style, {
left: `${left * zoomLevel}px`,
top: `${top * zoomLevel}px`,
width: `${width * zoomLevel}px`,
height: `${height * zoomLevel}px`
});
});
}
}
// Get viewer container and ID
function getViewerInfo() {
const container = document.querySelector('.e-pv-viewer-container');
if (!container) return null;
return { container, viewerId: container.id.replace('_viewerContainer', '') };
}
// Scroll to the element
function scrollIntoViewIfNeeded(element) {
if (!element) return;
const viewerInfo = getViewerInfo();
if (!viewerInfo) return;
const container = viewerInfo.container;
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const isVisible = elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom;
if (!isVisible) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
// For line level highlights
function clearLineHighlight() {
document.querySelectorAll('.highlight-box').forEach(box => box.remove());
}
// For selected-text highlights
function clearSelectedHighlights() {
document.querySelectorAll('.highlight-box-selected').forEach(box => box.remove());
}
// For clear all highlights
function clearAllHighlights() {
clearLineHighlight();
clearSelectedHighlights();
}
// Pause or resume speech synthesis
function readAloudMute(isPaused) {
const speechSynth = window.speechSynthesis;
isPaused ? speechSynth.resume() : speechSynth.pause();
}
// Cancel speech and remove highlights - Windows Speech Synthesis API
function cancelReading() {
if (window.speechSynthesis?.speaking) {
window.speechSynthesis.cancel();
clearAllHighlights();
}
}Step 3: Add the following code to the Home.razor file.
@page "/"
@using Syncfusion.Blazor.Buttons
@inject IJSRuntime JSRuntime
<SfButton Disabled="@IsPrevious" OnClick="@(async () => await PreviousLine())">Previous</SfButton>
<SfButton Disabled="@IsNext" OnClick="@(async () => await NextLine())">Next</SfButton>
<SfButton OnClick="Mute">Mute</SfButton>
<SfPdfViewer2 @ref="PdfViewer" DocumentPath="@DocumentPath" Height="100%" Width="100%">
<PdfViewerEvents DocumentUnloaded="OnDocumentUnloaded" DocumentLoaded="OnDocumentLoaded"
ZoomChanged="UpdateElement" PageChanged="OnPageChanged" OnTextSelectionEnd="OnTextSelectionEnd" />
</SfPdfViewer2>
@code {
private DotNetObjectReference<Home>? objRef;
private SfPdfViewer2? PdfViewer;
private string DocumentPath = "https://cdn.syncfusion.com/content/pdf/programmatical-annotations.pdf";
private bool muteVoice;
private int currentLineIndex = 0;
private bool IsPrevious = true;
private bool IsNext = true;
private bool ReadNextPage;
private bool ReadPreviousPage;
private int CurrentPage = 1;
protected override void OnInitialized()
{
objRef = DotNetObjectReference.Create(this);
}
// Called when PDF document is loaded
private async Task OnDocumentLoaded()
{
IsNext = false;
}
// Register .NET object for JS interop after first render
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JSRuntime.InvokeVoidAsync("registerDotNetObject", objRef);
}
}
// Called when PDF document is unloaded
private async Task OnDocumentUnloaded()
{
await JSRuntime.InvokeVoidAsync("cancelReading");
CurrentPage = 1;
currentLineIndex = 0;
IsPrevious = true;
}
// Handles page change events
private async Task OnPageChanged()
{
if (ReadNextPage)
{
currentLineIndex = 0;
await NextLine();
ReadNextPage = false;
}
else if (ReadPreviousPage)
{
await PreviousLine(true);
ReadPreviousPage = false;
}
}
// Updates highlight box based on zoom and current line
private async Task UpdateElement()
{
double? ZoomValue = PdfViewer?.ZoomValue / 100.0;
await JSRuntime.InvokeVoidAsync("updateHighlightBox", PdfViewer?.CurrentPageNumber - 1, currentLineIndex - 1, ZoomValue);
}
// Toggles mute/unmute for reading aloud
private async Task Mute()
{
await JSRuntime.InvokeVoidAsync("readAloudMute", muteVoice);
muteVoice = !muteVoice;
}
// Reads the previous line or navigates to the previous page
private async Task PreviousLine(bool isPreviousPage = false)
{
if (PdfViewer != null && currentLineIndex > 0 || isPreviousPage)
{
if (CurrentPage != PdfViewer!.CurrentPageNumber && !ReadPreviousPage)
{
await PdfViewer.GoToPageAsync(CurrentPage);
}
else
{
CurrentPage = PdfViewer.CurrentPageNumber;
}
currentLineIndex--;
await JSRuntime.InvokeVoidAsync("readLineFromPage", CurrentPage - 1, currentLineIndex - 1, isPreviousPage, muteVoice);
if (isPreviousPage)
currentLineIndex = await JSRuntime.InvokeAsync<int>("updateLineIndex", CurrentPage - 1);
IsPrevious = currentLineIndex < 1 && isPreviousPage;
IsNext = false;
}
}
// Reads the next line in the PDF
private async Task NextLine()
{
if (CurrentPage != PdfViewer!.CurrentPageNumber && !ReadNextPage)
{
await PdfViewer.GoToPageAsync(CurrentPage);
}
else
{
CurrentPage = PdfViewer.CurrentPageNumber;
}
currentLineIndex++;
await JSRuntime.InvokeVoidAsync("readLineFromPage", CurrentPage - 1, currentLineIndex - 1, false, muteVoice);
IsPrevious = currentLineIndex < 1;
IsNext = false;
}
// Reads selected text aloud
private async Task OnTextSelectionEnd(TextSelectionEndEventArgs args)
{
double? ZoomValue = PdfViewer?.ZoomValue / 100.0;
await JSRuntime.InvokeVoidAsync("readSelectedText", args, ZoomValue, muteVoice);
}
// Navigates to the next page
[JSInvokable]
private async Task GoNextPage()
{
if (PdfViewer!.CurrentPageNumber == PdfViewer.PageCount)
{
await PdfViewer.GoToFirstPageAsync();
}
else
{
await PdfViewer.GoToNextPageAsync();
}
ReadNextPage = true;
}
// Navigates to the previous page
[JSInvokable]
private async Task GoPreviousPage()
{
if (PdfViewer!.CurrentPageNumber == 1)
{
await PdfViewer.GoToLastPageAsync();
}
else
{
await PdfViewer!.GoToPreviousPageAsync();
}
ReadPreviousPage = true;
}
}