777 lines
30 KiB
PHP
777 lines
30 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/includes/layout.php';
|
|
|
|
library_bootstrap();
|
|
|
|
$documentId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
|
|
// Context handling (admin/public)
|
|
$context = ($_GET['context'] ?? '') === 'admin' ? 'admin' : 'public';
|
|
$publicOnly = $context !== 'admin';
|
|
|
|
$document = $documentId > 0 ? library_fetch_document($documentId, $publicOnly) : null;
|
|
|
|
if ($document && $context !== 'admin') {
|
|
library_track_request('reader_opened', (int) $document['id'], [
|
|
'document_type' => (string) ($document['document_type'] ?? ''),
|
|
]);
|
|
}
|
|
|
|
if (!$document || empty($document['file_path'])) {
|
|
http_response_code(404);
|
|
die('Document not found or no file attached.');
|
|
}
|
|
|
|
$fileUrl = library_file_url((string) $document['file_path']);
|
|
|
|
// --- Localization ---
|
|
// Use global helper to respect session/get
|
|
$lang = library_get_language();
|
|
$isRtl = $lang === 'ar';
|
|
|
|
$trans = [
|
|
'en' => [
|
|
'title_suffix' => 'Reader',
|
|
'default_title' => 'Document Viewer',
|
|
'back_tooltip' => 'Back to Details',
|
|
'search_placeholder' => 'Search...',
|
|
'prev_match' => 'Previous Match',
|
|
'next_match' => 'Next Match',
|
|
'read_aloud' => 'Read Aloud (Play/Stop)',
|
|
'prev_page' => 'Previous Page',
|
|
'next_page' => 'Next Page',
|
|
'download' => 'Download PDF',
|
|
'loading' => 'Loading Document...',
|
|
'downloading' => 'Downloading...',
|
|
'rendering' => 'Rendering pages...',
|
|
'error_prefix' => 'Error: ',
|
|
'searching' => 'Searching...',
|
|
'no_matches' => 'No matches',
|
|
'matches_found' => 'matches',
|
|
'page_label' => 'Page',
|
|
'switch_lang' => 'العربية',
|
|
'switch_lang_code' => 'ar'
|
|
],
|
|
'ar' => [
|
|
'title_suffix' => 'القارئ',
|
|
'default_title' => 'عارض المستندات',
|
|
'back_tooltip' => 'العودة للتفاصيل',
|
|
'search_placeholder' => 'بحث...',
|
|
'prev_match' => 'المطابقة السابقة',
|
|
'next_match' => 'المطابقة التالية',
|
|
'read_aloud' => 'قراءة بصوت عالٍ (تشغيل/إيقاف)',
|
|
'prev_page' => 'الصفحة السابقة',
|
|
'next_page' => 'الصفحة التالية',
|
|
'download' => 'تحميل PDF',
|
|
'loading' => 'جاري تحميل المستند...',
|
|
'downloading' => 'جاري التنزيل...',
|
|
'rendering' => 'جاري عرض الصفحات...',
|
|
'error_prefix' => 'خطأ: ',
|
|
'searching' => 'جاري البحث...',
|
|
'no_matches' => 'لا يوجد نتائج',
|
|
'matches_found' => 'تطابقات',
|
|
'page_label' => 'صفحة',
|
|
'switch_lang' => 'English',
|
|
'switch_lang_code' => 'en'
|
|
]
|
|
];
|
|
|
|
$t = $trans[$lang];
|
|
|
|
// Determine Title
|
|
$docTitle = $document['title_' . $lang] ?: $document['title_en'] ?: $document['title_ar'] ?: $t['default_title'];
|
|
$docTitleLang = library_text_lang(
|
|
(string) $docTitle,
|
|
!empty($document['title_ar']) && empty($document['title_en']) ? 'ar' : $lang
|
|
);
|
|
$docTitleDir = library_text_dir((string) $docTitle, $docTitleLang);
|
|
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="<?= $lang ?>" dir="<?= $isRtl ? 'rtl' : 'ltr' ?>">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title><?= h($docTitle) ?> - <?= $t['title_suffix'] ?></title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<!-- Icons -->
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
<!-- Bootstrap -->
|
|
<?php if ($isRtl): ?>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
|
|
<?php else: ?>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<?php endif; ?>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
font-family: Inter, "Noto Sans Arabic", "Segoe UI", Tahoma, sans-serif;
|
|
padding: 0;
|
|
background-color: #2d3035;
|
|
height: 100vh;
|
|
width: 100vw;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
color: #fff;
|
|
}
|
|
|
|
#viewer-container {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
position: relative;
|
|
overflow: hidden;
|
|
padding: 20px;
|
|
}
|
|
|
|
#flipbook-loader {
|
|
text-align: center;
|
|
}
|
|
|
|
#flipbook {
|
|
display: none;
|
|
}
|
|
|
|
.page {
|
|
background-color: #fff;
|
|
box-shadow: inset -1px 0 2px rgba(0,0,0,0.1); /* subtle spine shadow */
|
|
}
|
|
|
|
#toolbar {
|
|
height: 60px;
|
|
background-color: #1a1d21;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 20px;
|
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.2);
|
|
z-index: 1000;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.toolbar-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.toolbar-btn {
|
|
background: none;
|
|
border: none;
|
|
color: #adb5bd;
|
|
font-size: 1.2rem;
|
|
cursor: pointer;
|
|
padding: 5px 10px;
|
|
border-radius: 4px;
|
|
transition: color 0.2s, background-color 0.2s;
|
|
text-decoration: none;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.toolbar-btn:hover {
|
|
color: #fff;
|
|
background-color: rgba(255,255,255,0.1);
|
|
}
|
|
|
|
.toolbar-btn.active {
|
|
color: #ffc107; /* Active state color (yellow) */
|
|
background-color: rgba(255, 255, 255, 0.15);
|
|
}
|
|
|
|
.page-indicator {
|
|
font-family: monospace;
|
|
font-size: 1.1rem;
|
|
color: #fff;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.doc-title {
|
|
font-weight: 500;
|
|
unicode-bidi: plaintext;
|
|
text-align: start;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 200px;
|
|
}
|
|
|
|
/* Search Styles */
|
|
.search-container {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
background: #2d3035;
|
|
border-radius: 4px;
|
|
padding: 2px;
|
|
border: 1px solid #495057;
|
|
}
|
|
|
|
#searchInput {
|
|
background: transparent;
|
|
border: none;
|
|
color: #fff;
|
|
padding: 5px 10px;
|
|
outline: none;
|
|
width: 150px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
#searchInput::placeholder {
|
|
color: #adb5bd;
|
|
}
|
|
|
|
[lang="ar"],
|
|
[dir="rtl"] {
|
|
font-family: "Noto Sans Arabic", "Segoe UI", Tahoma, sans-serif;
|
|
unicode-bidi: plaintext;
|
|
text-align: start;
|
|
}
|
|
|
|
.search-controls {
|
|
display: flex;
|
|
border-inline-start: 1px solid #495057; /* RTL safe border */
|
|
}
|
|
|
|
.search-btn {
|
|
background: none;
|
|
border: none;
|
|
color: #adb5bd;
|
|
padding: 5px 8px;
|
|
cursor: pointer;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.search-btn:hover {
|
|
color: #fff;
|
|
background-color: rgba(255,255,255,0.1);
|
|
}
|
|
|
|
#search-status {
|
|
font-size: 0.8rem;
|
|
color: #adb5bd;
|
|
margin-inline-start: 8px; /* RTL safe margin */
|
|
white-space: nowrap;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="toolbar">
|
|
<div class="toolbar-group">
|
|
<a href="/document.php?id=<?= $documentId ?><?= $context === 'admin' ? '&context=admin' : '' ?>" class="toolbar-btn" title="<?= h($t['back_tooltip']) ?>">
|
|
<i class="bi bi-arrow-left<?= $isRtl ? '-right' : '' /* flip arrow if needed? Actually left arrow usually points back in LTR, but in RTL 'Back' might be Right arrow? Let's check Bootstrap icons. bi-arrow-left points left. In RTL, back is usually Right. */ ?>"></i>
|
|
<!-- Note: In RTL interfaces, 'Back' usually implies moving to the 'parent' or 'previous' screen.
|
|
The arrow should point in the direction of flow.
|
|
Bootstrap 5 RTL mode mirrors some things, but icons often need manual flipping.
|
|
Let's use specific logic: if RTL, use arrow-right for 'Back'. -->
|
|
</a>
|
|
<div class="doc-title d-none d-lg-block" lang="<?= h($docTitleLang) ?>" dir="<?= h($docTitleDir) ?>"><?= h($docTitle) ?></div>
|
|
</div>
|
|
|
|
<!-- Center: Search -->
|
|
<div class="toolbar-group d-none d-md-flex">
|
|
<div class="search-container">
|
|
<input type="text" id="searchInput" placeholder="<?= h($t['search_placeholder']) ?>" autocomplete="off">
|
|
<div class="search-controls">
|
|
<button id="btn-search-prev" class="search-btn" title="<?= h($t['prev_match']) ?>"><i class="bi bi-chevron-up"></i></button>
|
|
<button id="btn-search-next" class="search-btn" title="<?= h($t['next_match']) ?>"><i class="bi bi-chevron-down"></i></button>
|
|
</div>
|
|
</div>
|
|
<span id="search-status"></span>
|
|
</div>
|
|
|
|
<div class="toolbar-group">
|
|
<button id="btn-read" class="toolbar-btn" title="<?= h($t['read_aloud']) ?>">
|
|
<i class="bi bi-megaphone"></i>
|
|
</button>
|
|
<div class="vr mx-2 bg-secondary" style="opacity: 0.5;"></div>
|
|
|
|
<!-- Lang Switcher -->
|
|
<a href="?id=<?= $documentId ?>&context=<?= $context ?>&lang=<?= $t['switch_lang_code'] ?>" class="toolbar-btn" style="font-size: 0.9rem; font-weight: bold;">
|
|
<?= $t['switch_lang'] ?>
|
|
</a>
|
|
|
|
<div class="vr mx-2 bg-secondary" style="opacity: 0.5;"></div>
|
|
|
|
<button id="btn-prev" class="toolbar-btn" title="<?= h($t['prev_page']) ?>">
|
|
<i class="bi bi-caret-left-fill"></i>
|
|
</button>
|
|
<div class="page-indicator">
|
|
<span id="page-current">1</span> <span class="text-secondary">/</span> <span id="page-total">--</span>
|
|
</div>
|
|
<button id="btn-next" class="toolbar-btn" title="<?= h($t['next_page']) ?>">
|
|
<i class="bi bi-caret-right-fill"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="toolbar-group">
|
|
<a href="<?= h($fileUrl) ?>" download class="toolbar-btn" title="<?= h($t['download']) ?>">
|
|
<i class="bi bi-download"></i>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="viewer-container">
|
|
<div id="flipbook-loader">
|
|
<div class="spinner-border text-light mb-3" role="status"></div>
|
|
<div><?= h($t['loading']) ?></div>
|
|
<div id="loader-status" class="text-secondary small mt-2"></div>
|
|
</div>
|
|
<div id="flipbook"></div>
|
|
</div>
|
|
|
|
<!-- Scripts -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
|
<script src="https://unpkg.com/page-flip/dist/js/page-flip.browser.js"></script>
|
|
<script>
|
|
// Translations from PHP
|
|
const TR = <?= json_encode($t) ?>;
|
|
const IS_RTL = <?= $isRtl ? 'true' : 'false' ?>;
|
|
|
|
// Global State
|
|
let pdfDoc = null;
|
|
let pageFlip = null;
|
|
let searchResults = []; // Array of { pageIndex, rects: [x,y,w,h] }
|
|
let currentMatchIndex = -1;
|
|
let pageHighlighters = {}; // Map of pageIndex -> CanvasContext (for highlights)
|
|
let pageViewports = {}; // Map of pageIndex -> viewport (for rect calc)
|
|
|
|
// Speech State
|
|
let isSpeaking = false;
|
|
let speechUtterance = null;
|
|
let shouldContinueReading = false;
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
const pdfUrl = '<?= h($fileUrl) ?>';
|
|
const container = document.getElementById('flipbook');
|
|
const loader = document.getElementById('flipbook-loader');
|
|
const loaderStatus = document.getElementById('loader-status');
|
|
const currentSpan = document.getElementById('page-current');
|
|
const totalSpan = document.getElementById('page-total');
|
|
const viewerContainer = document.getElementById('viewer-container');
|
|
|
|
// Set worker
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
|
|
|
try {
|
|
const loadingTask = pdfjsLib.getDocument(pdfUrl);
|
|
loadingTask.onProgress = (p) => {
|
|
if (p.total > 0) {
|
|
const percent = Math.round((p.loaded / p.total) * 100);
|
|
loaderStatus.textContent = `${TR.downloading} ${percent}%`;
|
|
}
|
|
};
|
|
|
|
pdfDoc = await loadingTask.promise;
|
|
const totalPages = pdfDoc.numPages;
|
|
totalSpan.textContent = totalPages;
|
|
loaderStatus.textContent = TR.rendering;
|
|
|
|
// --- Layout Logic ---
|
|
const availWidth = viewerContainer.clientWidth - 40;
|
|
const availHeight = viewerContainer.clientHeight - 40;
|
|
const firstPage = await pdfDoc.getPage(1);
|
|
const viewport = firstPage.getViewport({ scale: 1 });
|
|
const aspectRatio = viewport.width / viewport.height;
|
|
|
|
let bookHeight = availHeight;
|
|
let bookWidth = bookHeight * aspectRatio;
|
|
if (bookWidth * 2 > availWidth) {
|
|
bookWidth = availWidth / 2;
|
|
bookHeight = bookWidth / aspectRatio;
|
|
}
|
|
|
|
const deviceScale = Math.max(1, window.devicePixelRatio || 1);
|
|
const displayScale = (bookHeight / viewport.height);
|
|
const renderScale = Math.max(displayScale * deviceScale * 1.6, 2.2);
|
|
|
|
// --- Page Generation ---
|
|
const canvasPromises = [];
|
|
|
|
for (let i = 1; i <= totalPages; i++) {
|
|
const pageDiv = document.createElement('div');
|
|
pageDiv.className = 'page';
|
|
|
|
// Structure:
|
|
// .page-content (relative)
|
|
// -> .pdf-canvas (absolute, z=1)
|
|
// -> .highlight-canvas (absolute, z=2, pointer-events: none)
|
|
const contentDiv = document.createElement('div');
|
|
contentDiv.style.position = 'relative';
|
|
contentDiv.style.width = '100%';
|
|
contentDiv.style.height = '100%';
|
|
contentDiv.style.overflow = 'hidden';
|
|
|
|
const pdfCanvas = document.createElement('canvas');
|
|
pdfCanvas.style.position = 'absolute';
|
|
pdfCanvas.style.top = '0';
|
|
pdfCanvas.style.left = '0';
|
|
pdfCanvas.style.width = '100%';
|
|
pdfCanvas.style.height = '100%';
|
|
|
|
const hlCanvas = document.createElement('canvas');
|
|
hlCanvas.style.position = 'absolute';
|
|
hlCanvas.style.top = '0';
|
|
hlCanvas.style.left = '0';
|
|
hlCanvas.style.width = '100%';
|
|
hlCanvas.style.height = '100%';
|
|
hlCanvas.style.pointerEvents = 'none'; // Click through to page
|
|
|
|
contentDiv.appendChild(pdfCanvas);
|
|
contentDiv.appendChild(hlCanvas);
|
|
pageDiv.appendChild(contentDiv);
|
|
container.appendChild(pageDiv);
|
|
|
|
// Render Async
|
|
canvasPromises.push(async () => {
|
|
const page = await pdfDoc.getPage(i);
|
|
const renderViewport = page.getViewport({ scale: renderScale });
|
|
const displayViewport = page.getViewport({ scale: displayScale });
|
|
|
|
// Setup PDF Canvas
|
|
pdfCanvas.height = Math.ceil(renderViewport.height);
|
|
pdfCanvas.width = Math.ceil(renderViewport.width);
|
|
pdfCanvas.style.width = `${displayViewport.width}px`;
|
|
pdfCanvas.style.height = `${displayViewport.height}px`;
|
|
const ctx = pdfCanvas.getContext('2d', { alpha: false });
|
|
|
|
// Setup Highlight Canvas (match display dims for search overlay)
|
|
hlCanvas.height = Math.ceil(displayViewport.height);
|
|
hlCanvas.width = Math.ceil(displayViewport.width);
|
|
hlCanvas.style.width = `${displayViewport.width}px`;
|
|
hlCanvas.style.height = `${displayViewport.height}px`;
|
|
const hlCtx = hlCanvas.getContext('2d');
|
|
|
|
// Store refs for search
|
|
pageHighlighters[i] = hlCtx;
|
|
pageViewports[i] = displayViewport;
|
|
|
|
await page.render({ canvasContext: ctx, viewport: renderViewport }).promise;
|
|
});
|
|
}
|
|
|
|
await Promise.all(canvasPromises.map(fn => fn()));
|
|
|
|
// --- Init Flipbook ---
|
|
loader.style.display = 'none';
|
|
container.style.display = 'block';
|
|
|
|
pageFlip = new St.PageFlip(container, {
|
|
width: bookWidth,
|
|
height: bookHeight,
|
|
size: 'fixed',
|
|
showCover: true,
|
|
maxShadowOpacity: 0.5,
|
|
direction: IS_RTL ? 'rtl' : 'ltr'
|
|
});
|
|
|
|
pageFlip.loadFromHTML(document.querySelectorAll('.page'));
|
|
|
|
// --- Events ---
|
|
pageFlip.on('flip', (e) => {
|
|
document.getElementById('page-current').textContent = (pageFlip.getCurrentPageIndex() + 1);
|
|
|
|
// Note: Manual flips are now handled by button/key listeners stopping speech.
|
|
// But if flip is triggered programmatically by advancePage(), we want to keep reading.
|
|
// The 'shouldContinueReading' flag handles the advancePage() recursion.
|
|
});
|
|
|
|
document.getElementById('btn-prev').addEventListener('click', () => {
|
|
if (isSpeaking) stopSpeech();
|
|
pageFlip.flipPrev();
|
|
});
|
|
|
|
document.getElementById('btn-next').addEventListener('click', () => {
|
|
if (isSpeaking) stopSpeech();
|
|
pageFlip.flipNext();
|
|
});
|
|
|
|
document.getElementById('btn-read').addEventListener('click', toggleRead);
|
|
|
|
// Keyboard
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.target.tagName === 'INPUT') return; // Don't flip when typing
|
|
|
|
// In RTL, left arrow should technically go next?
|
|
// Usually: Right Arrow = Next in LTR.
|
|
// In RTL Book: Left Arrow = Next (turn page to left).
|
|
// But PageFlip library might handle 'flipNext' logically.
|
|
// Let's test standard logical mapping first:
|
|
// Right Arrow -> Next Page (Logic)
|
|
// Left Arrow -> Prev Page (Logic)
|
|
|
|
if (e.key === 'ArrowLeft') {
|
|
if (isSpeaking) stopSpeech();
|
|
// If RTL, Left Arrow might mean "Next" physically?
|
|
// Let's stick to logical "Prev" and "Next" buttons which call flipPrev/flipNext.
|
|
// Ideally:
|
|
// LTR: Left = Prev, Right = Next
|
|
// RTL: Right = Prev, Left = Next
|
|
|
|
if (IS_RTL) {
|
|
pageFlip.flipNext(); // Left goes forward in RTL book (turning page from left to right? No, right to left.)
|
|
// RTL book: Pages 1, 2, 3...
|
|
// Page 1 is on Right.
|
|
// To go to Page 2, you drag Right page to Left.
|
|
// So "Left" direction is Next.
|
|
} else {
|
|
pageFlip.flipPrev();
|
|
}
|
|
}
|
|
if (e.key === 'ArrowRight') {
|
|
if (isSpeaking) stopSpeech();
|
|
if (IS_RTL) {
|
|
pageFlip.flipPrev(); // Right goes back
|
|
} else {
|
|
pageFlip.flipNext();
|
|
}
|
|
}
|
|
});
|
|
|
|
// --- Search Implementation ---
|
|
setupSearch();
|
|
|
|
} catch (error) {
|
|
console.error(error);
|
|
loader.innerHTML = '<div class="text-danger p-3">' + TR.error_prefix + error.message + '</div>';
|
|
}
|
|
});
|
|
|
|
// --- Speech Logic ---
|
|
function toggleRead() {
|
|
if (isSpeaking) {
|
|
stopSpeech();
|
|
} else {
|
|
startReading();
|
|
}
|
|
}
|
|
|
|
async function startReading() {
|
|
if (!pdfDoc) return;
|
|
|
|
const btn = document.getElementById('btn-read');
|
|
const icon = btn.querySelector('i');
|
|
|
|
// UI Update
|
|
isSpeaking = true;
|
|
shouldContinueReading = true; // Enable auto-flip
|
|
btn.classList.add('active');
|
|
icon.className = 'bi bi-stop-circle'; // Change to stop icon
|
|
|
|
await readCurrentPage();
|
|
}
|
|
|
|
function stopSpeech() {
|
|
window.speechSynthesis.cancel();
|
|
isSpeaking = false;
|
|
shouldContinueReading = false;
|
|
|
|
const btn = document.getElementById('btn-read');
|
|
const icon = btn.querySelector('i');
|
|
btn.classList.remove('active');
|
|
icon.className = 'bi bi-megaphone';
|
|
}
|
|
|
|
async function readCurrentPage() {
|
|
if (!isSpeaking) return;
|
|
|
|
// Get current PDF page index (0-based from PageFlip + 1 for PDF)
|
|
// PageFlip index 0 = PDF page 1
|
|
const pageIndex = pageFlip.getCurrentPageIndex();
|
|
const pdfPageIndex = pageIndex + 1;
|
|
|
|
if (pdfPageIndex > pdfDoc.numPages) {
|
|
stopSpeech();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const page = await pdfDoc.getPage(pdfPageIndex);
|
|
const textContent = await page.getTextContent();
|
|
|
|
// Simple text extraction: join items with space
|
|
const text = textContent.items.map(item => item.str).join(' ');
|
|
|
|
if (!text || text.trim().length === 0) {
|
|
// Empty page? Try next
|
|
if (shouldContinueReading) {
|
|
advancePage();
|
|
} else {
|
|
stopSpeech();
|
|
}
|
|
return;
|
|
}
|
|
|
|
speechUtterance = new SpeechSynthesisUtterance(text);
|
|
speechUtterance.rate = 1.0;
|
|
if (IS_RTL) {
|
|
speechUtterance.lang = 'ar-SA'; // Suggest Arabic voice if in Arabic mode
|
|
}
|
|
|
|
speechUtterance.onend = () => {
|
|
if (isSpeaking && shouldContinueReading) {
|
|
advancePage();
|
|
}
|
|
};
|
|
|
|
speechUtterance.onerror = (e) => {
|
|
console.error('Speech error:', e);
|
|
stopSpeech();
|
|
};
|
|
|
|
window.speechSynthesis.speak(speechUtterance);
|
|
|
|
} catch (e) {
|
|
console.error('Reading failed:', e);
|
|
stopSpeech();
|
|
}
|
|
}
|
|
|
|
function advancePage() {
|
|
// Flip to next page
|
|
// Logic: if current page < total - 1
|
|
const nextIndex = pageFlip.getCurrentPageIndex() + 1;
|
|
if (nextIndex < pdfDoc.numPages) {
|
|
pageFlip.flipNext();
|
|
|
|
setTimeout(() => {
|
|
if (isSpeaking) readCurrentPage();
|
|
}, 800); // 800ms for page turn animation approx
|
|
} else {
|
|
stopSpeech();
|
|
}
|
|
}
|
|
|
|
// --- Search Logic ---
|
|
function setupSearch() {
|
|
const input = document.getElementById('searchInput');
|
|
const btnPrev = document.getElementById('btn-search-prev');
|
|
const btnNext = document.getElementById('btn-search-next');
|
|
const status = document.getElementById('search-status');
|
|
|
|
let debounceTimer;
|
|
|
|
input.addEventListener('input', (e) => {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(() => {
|
|
performSearch(e.target.value.trim());
|
|
}, 600);
|
|
});
|
|
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
clearTimeout(debounceTimer);
|
|
performSearch(input.value.trim());
|
|
}
|
|
});
|
|
|
|
btnNext.addEventListener('click', () => {
|
|
if (searchResults.length === 0) return;
|
|
currentMatchIndex = (currentMatchIndex + 1) % searchResults.length;
|
|
showMatch(currentMatchIndex);
|
|
});
|
|
|
|
btnPrev.addEventListener('click', () => {
|
|
if (searchResults.length === 0) return;
|
|
currentMatchIndex = (currentMatchIndex - 1 + searchResults.length) % searchResults.length;
|
|
showMatch(currentMatchIndex);
|
|
});
|
|
}
|
|
|
|
async function performSearch(query) {
|
|
const status = document.getElementById('search-status');
|
|
|
|
// Clear previous highlights
|
|
Object.values(pageHighlighters).forEach(ctx => {
|
|
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
});
|
|
|
|
if (!query || query.length < 2) {
|
|
searchResults = [];
|
|
currentMatchIndex = -1;
|
|
status.textContent = '';
|
|
return;
|
|
}
|
|
|
|
status.textContent = TR.searching;
|
|
searchResults = [];
|
|
currentMatchIndex = -1;
|
|
|
|
try {
|
|
// Loop all pages
|
|
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
|
const page = await pdfDoc.getPage(i);
|
|
const textContent = await page.getTextContent();
|
|
const viewport = pageViewports[i]; // Use stored viewport
|
|
const ctx = pageHighlighters[i];
|
|
|
|
textContent.items.forEach(item => {
|
|
if (item.str.toLowerCase().includes(query.toLowerCase())) {
|
|
|
|
const tx = item.transform;
|
|
let x = tx[4];
|
|
let y = tx[5];
|
|
let w = item.width;
|
|
let h = Math.sqrt(tx[0]*tx[0] + tx[1]*tx[1]); // approximate font size from scale
|
|
|
|
if (!w) w = h * item.str.length * 0.5; // fallback
|
|
|
|
const rect = [x, y, x + w, y + h];
|
|
const viewRect = viewport.convertToViewportRectangle(rect);
|
|
|
|
const rx = Math.min(viewRect[0], viewRect[2]);
|
|
const ry = Math.min(viewRect[1], viewRect[3]);
|
|
const rw = Math.abs(viewRect[0] - viewRect[2]);
|
|
const rh = Math.abs(viewRect[1] - viewRect[3]);
|
|
|
|
// Draw highlight (Yellow, 40% opacity)
|
|
ctx.fillStyle = 'rgba(255, 235, 59, 0.4)';
|
|
ctx.fillRect(rx, ry, rw, rh);
|
|
|
|
// Add border for visibility
|
|
ctx.strokeStyle = 'rgba(255, 193, 7, 0.8)';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(rx, ry, rw, rh);
|
|
|
|
searchResults.push({
|
|
pageIndex: i - 1, // 0-based for PageFlip
|
|
label: `${TR.page_label} ${i}`
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
if (searchResults.length > 0) {
|
|
currentMatchIndex = 0;
|
|
status.textContent = `${searchResults.length} ${TR.matches_found}`;
|
|
showMatch(0);
|
|
} else {
|
|
status.textContent = TR.no_matches;
|
|
}
|
|
|
|
} catch (e) {
|
|
console.error('Search failed', e);
|
|
status.textContent = TR.error_prefix + 'Search';
|
|
}
|
|
}
|
|
|
|
function showMatch(index) {
|
|
if (index < 0 || index >= searchResults.length) return;
|
|
const match = searchResults[index];
|
|
|
|
// Update status text
|
|
document.getElementById('search-status').textContent = `${index + 1} / ${searchResults.length} ${TR.matches_found}`;
|
|
|
|
pageFlip.flip(match.pageIndex);
|
|
}
|
|
|
|
</script>
|
|
</body>
|
|
</html>
|