1const loginPanel = document.querySelector("#loginPanel"); 2const workspace = document.querySelector("#workspace"); 3const loginForm = document.querySelector("#loginForm"); 4const codeInput = document.querySelector("#codeInput"); 5const loginError = document.querySelector("#loginError"); 6const logoutButton = document.querySelector("#logoutButton"); 7const uploadForm = document.querySelector("#uploadForm"); 8const pdfInput = document.querySelector("#pdfInput"); 9const fileName = document.querySelector("#fileName"); 10const createButton = document.querySelector("#createButton"); 11const compareButton = document.querySelector("#compareButton"); 12const voiceSelect = document.querySelector("#voiceSelect"); 13const speedSelect = document.querySelector("#speedSelect"); 14const ocrModeSelect = document.querySelector("#ocrModeSelect"); 15const pageLimitSelect = document.querySelector("#pageLimitSelect"); 16const uploadError = document.querySelector("#uploadError"); 17const engineNotice = document.querySelector("#engineNotice"); 18const jobTitle = document.querySelector("#jobTitle"); 19const jobState = document.querySelector("#jobState"); 20const progressBar = document.querySelector("#progressBar"); 21const progressMeter = document.querySelector(".meter"); 22const progressPercent = document.querySelector("#progressPercent"); 23const stageLabel = document.querySelector("#stageLabel"); 24const stagePhaseLabel = document.querySelector("#stagePhaseLabel"); 25const stageDetailTitle = document.querySelector("#stageDetailTitle"); 26const stageDetailText = document.querySelector("#stageDetailText"); 27const stageItemProgress = document.querySelector("#stageItemProgress"); 28const stageItemLabel = document.querySelector("#stageItemLabel"); 29const stageItemValue = document.querySelector("#stageItemValue"); 30const stageItemBar = document.querySelector("#stageItemBar"); 31const stageMetaText = document.querySelector("#stageMetaText"); 32const stageList = document.querySelector("#stageList"); 33const jobMessage = document.querySelector("#jobMessage"); 34const qualityHint = document.querySelector("#qualityHint"); 35const transcriptPanel = document.querySelector("#transcriptPanel"); 36const transcriptText = document.querySelector("#transcriptText"); 37const transcriptMeta = document.querySelector("#transcriptMeta"); 38const transcriptRefreshButton = document.querySelector("#transcriptRefreshButton"); 39const pagesValue = document.querySelector("#pagesValue"); 40const charactersValue = document.querySelector("#charactersValue"); 41const engineValue = document.querySelector("#engineValue"); 42const extractionValue = document.querySelector("#extractionValue"); 43const qualityValue = document.querySelector("#qualityValue"); 44const audioFormatValue = document.querySelector("#audioFormatValue"); 45const audioPanel = document.querySelector("#audioPanel"); 46const audioPlayer = document.querySelector("#audioPlayer"); 47const downloadLink = document.querySelector("#downloadLink"); 48const browserSpeechPanel = document.querySelector("#browserSpeechPanel"); 49const browserSpeechMessage = document.querySelector("#browserSpeechMessage"); 50const browserSpeakButton = document.querySelector("#browserSpeakButton"); 51const browserStopButton = document.querySelector("#browserStopButton"); 52const comparisonPanel = document.querySelector("#comparisonPanel"); 53const comparisonTitle = document.querySelector("#comparisonTitle"); 54const comparisonState = document.querySelector("#comparisonState"); 55const comparisonMessage = document.querySelector("#comparisonMessage"); 56const comparisonBar = document.querySelector("#comparisonBar"); 57const comparisonResults = document.querySelector("#comparisonResults"); 58const historyPanel = document.querySelector("#historyPanel"); 59const historyList = document.querySelector("#historyList"); 60const libraryPanel = document.querySelector("#libraryPanel"); 61const libraryList = document.querySelector("#libraryList"); 62 63let pollTimer = null; 64let comparisonPollTimer = null; 65let maxUploadMb = null; 66let cloudMode = false; 67let hostedShellMode = false; 68let remoteWorkerMode = false; 69let workerBaseUrl = ""; 70let cloudTtsReady = false; 71let cloudTtsMaxChars = 900; 72let workerUnavailableDiagnostic = null; 73let currentAudioUrl = null; 74const defaultVoiceCatalog = { 75 default: "silma-local", 76 cloud: [ 77 { id: "mms-ara", label: "Arabic Standard" }, 78 { id: "silma-tts", label: "SILMA Arabic" }, 79 ], 80 local: [ 81 { id: "silma-local", label: "1. SILMA Arabic - Most natural" }, 82 { id: "espeak-ar-clear", label: "2. Local Arabic Clear - Fast fallback" }, 83 { id: "espeak-ar", label: "3. Local Arabic - Standard fallback" }, 84 { id: "espeak-ar-male", label: "Local Arabic Low" }, 85 ], 86}; 87let voiceCatalog = defaultVoiceCatalog; 88let ocrWorker = null; 89let pdfjsLib = null; 90let tesseractCreateWorker = null; 91let activeJobId = null; 92let activeComparisonId = null; 93let pendingUnlockCode = ""; 94let sessionUnlockCode = ""; 95let installedArabicOcrModels = []; 96let browserOcrProgress = { page: 0, total: 0 }; 97let browserSpeechText = ""; 98let browserSpeechSourceName = ""; 99let activeTranscriptUrl = ""; 100 101const ocrModeLabels = { 102 tesseract: "1. Tesseract Arabic - Best readable", 103 "tesseract-fast": "2. Tesseract Arabic - Faster readable", 104 paddleocr: "3. PaddleOCR Arabic - Faster fallback", 105 "arabic-max": "Maximum Arabic OCR - slower", 106 arabic: "Arabic OCR comparison - slower", 107 "qari-ocr": "QARI Arabic books (best)", 108 "tawkeed-ocr": "Tawkeed Arabic OCR", 109 "katib-ocr": "KATIB Arabic OCR (lighter)", 110 "arabic-qwen-ocr": "Arabic-Qwen OCR", 111 "arabic-glm-ocr": "Arabic-GLM OCR v2", 112 "baseer-ocr": "Baseer Arabic OCR", 113 "paddleocr-vl": "PaddleOCR-VL heavy", 114 best: "Best scan test", 115 surya: "Surya heavy OCR", 116 easyocr: "General Arabic OCR", 117 auto: "Auto fallback", 118}; 119 120const qualityLabels = { 121 good: "Good", 122 warning: "Check", 123 poor: "Poor", 124}; 125 126const pageLimitLabels = { 127 0: "Full book", 128 5: "Quick test", 129 10: "Longer test", 130}; 131 132const speedLabels = { 133 0.9: "Slower", 134 1: "Normal", 135 1.15: "Faster", 136}; 137 138const mixedPdfOcrMissingPageRatio = 0.15; 139 140const statusLabels = { 141 queued: "Queued", 142 reading: "Reading", 143 speaking: "Creating", 144 complete: "Ready", 145 failed: "Failed", 146}; 147 148const defaultStageSteps = [ 149 { id: "upload", label: "Upload" }, 150 { id: "text", label: "Text scan" }, 151 { id: "ocr", label: "Arabic OCR" }, 152 { id: "voice", label: "Voice" }, 153 { id: "ready", label: "Ready" }, 154]; 155const progressRanges = { 156 textStart: 5, 157 textEnd: 18, 158 ocrStart: 18, 159 ocrRenderEnd: 30, 160 ocrEnd: 72, 161 voiceStart: 72, 162 voiceEnd: 98, 163}; 164 165function rangeProgress(start, end, current, total) { 166 const safeTotal = Math.max(1, Number(total) || 1); 167 const safeCurrent = Math.max(0, Math.min(safeTotal, Number(current) || 0)); 168 return Math.round(start + (safeCurrent / safeTotal) * (end - start)); 169} 170 171function setAuthenticated(authenticated) { 172 loginPanel.classList.toggle("hidden", authenticated); 173 workspace.classList.toggle("hidden", !authenticated); 174 logoutButton.classList.toggle("hidden", !authenticated); 175 if (authenticated) { 176 uploadError.textContent = ""; 177 loadHealth().then(() => { 178 loadHistory(); 179 loadLibrary(); 180 }); 181 } 182} 183 184function setError(target, message) { 185 target.textContent = message || ""; 186} 187 188function formatDiagnosticMessage(diagnostic, fallback = "") { 189 const message = diagnostic?.message || fallback || ""; 190 const nextSteps = Array.isArray(diagnostic?.nextSteps)
191 ? diagnostic.nextSteps.filter(Boolean).slice(0, 3) 192 : []; 193 if (!nextSteps.length) return message; 194 return `${message} Next: ${nextSteps.join(" ")}`; 195} 196 197async function readJson(response) { 198 const payload = await response.json().catch(() => ({})); 199 if (!response.ok) { 200 const error = new Error(payload.detail || "Request failed"); 201 error.status = response.status; 202 throw error; 203 } 204 return payload; 205} 206 207function describeNetworkError(error) { 208 if (error?.name === "TypeError") { 209 if (remoteWorkerMode && workerBaseUrl) { 210 return ( 211 "The OCR/TTS worker could not be reached from this browser. The Space may be waking up, or the browser blocked the cross-site upload. " + 212 "Wait a minute, refresh, unlock with the code again, and retry." 213 ); 214 } 215 return "The site could not be reached. Check the connection and try again."; 216 } 217 return error?.message || "Request failed"; 218} 219 220function apiUrl(path) { 221 if (!remoteWorkerMode || path.startsWith("http")) return path; 222 return `${workerBaseUrl}${path}`; 223} 224 225function mediaUrl(path) { 226 if (!path || path.startsWith("http")) return path; 227 return path; 228} 229 230function apiFetch(path, options = {}) { 231 const credentials = remoteWorkerMode ? "include" : "same-origin"; 232 return fetch(apiUrl(path), { credentials, ...options }); 233} 234 235async function checkSession() { 236 const payload = await fetch("/api/session").then(readJson); 237 setAuthenticated(payload.authenticated); 238} 239 240async function loadHealth() { 241 try { 242 let payload = await fetch("/api/health").then(readJson); 243 let engines = payload.engines; 244 hostedShellMode = engines.deployment?.platform === "vercel"; 245 workerBaseUrl = engines.deployment?.workerBaseUrl || ""; 246 remoteWorkerMode = Boolean(hostedShellMode && workerBaseUrl); 247 workerUnavailableDiagnostic = null; 248 let workerDiagnostic = null; 249 250 if (remoteWorkerMode) { 251 cloudMode = false; 252 cloudTtsReady = false; 253 maxUploadMb = payload.maxUploadMb || 512; 254 voiceCatalog = defaultVoiceCatalog; 255 renderVoiceOptions(); 256 engineNotice.textContent = "The OCR/TTS worker is connected. You can upload now while voice details finish checking."; 257 engineNotice.classList.remove("warning"); 258 } 259 260 if (remoteWorkerMode) { 261 await loginRemoteWorker(); 262 try { 263 payload = await apiFetch("/api/health").then(readJson); 264 } catch (error) { 265 if (error.status === 401) { 266 await requireWorkerUnlock(); 267 return; 268 } 269 workerDiagnostic = await loadWorkerDiagnostic(error); 270 workerUnavailableDiagnostic = workerDiagnostic; 271 remoteWorkerMode = false; 272 } 273 if (remoteWorkerMode) { 274 engines = payload.engines; 275 } 276 } else if (hostedShellMode && !workerBaseUrl) { 277 workerDiagnostic = await loadWorkerDiagnostic({ message: engines.deployment?.nextAction || "" }); 278 workerUnavailableDiagnostic = workerDiagnostic; 279 } 280 281 cloudMode = hostedShellMode && !remoteWorkerMode && !workerBaseUrl; 282 cloudTtsReady = Boolean(cloudMode && engines.cloudTts?.available); 283 cloudTtsMaxChars = engines.cloudTts?.maxChunkChars || 900; 284 voiceCatalog = engines.voices || voiceCatalog || defaultVoiceCatalog; 285 installedArabicOcrModels = engines.ocr?.arabicTrainedStack?.installed || []; 286 renderVoiceOptions(); 287 if (!cloudMode || remoteWorkerMode) { 288 setSelectValue(ocrModeSelect, engines.ocr?.preferred); 289 } 290 maxUploadMb = cloudMode ? (cloudTtsReady ? engines.cloudTts?.maxPdfMb || 512 : null) : payload.maxUploadMb; 291 ocrModeSelect.disabled = Boolean(cloudMode && !remoteWorkerMode); 292 293 const deploymentStatus = engines.deployment || {}; 294 if (workerDiagnostic) { 295 deploymentStatus.workerDiagnostic = workerDiagnostic; 296 } 297 const deploymentAction = deploymentStatus.nextAction || ""; 298 299 if (remoteWorkerMode) { 300 const label = engines.preferred === "silma" ? "SILMA Arabic voice" : "Arabic worker"; 301 engineNotice.textContent = 302 deploymentStatus.productionReady === false 303 ? deploymentAction || "The worker is connected, but Vercel production settings still need attention." 304 : `${label} is ready on the OCR/TTS worker. Uploads up to ${payload.maxUploadMb} MB are accepted.`; 305 engineNotice.classList.toggle("warning", deploymentStatus.productionReady === false); 306 return; 307 } 308 309 if (cloudMode && cloudTtsReady) { 310 engineNotice.textContent = 311 formatDiagnosticMessage(deploymentStatus.workerDiagnostic) || 312 "Temporary cloud voice test mode is enabled. For reliable downloadable audio on Vercel, add WORKER_BASE_URL and redeploy."; 313 engineNotice.classList.add("warning"); 314 return; 315 } 316 317 if (hostedShellMode && workerBaseUrl && !remoteWorkerMode) { 318 engineNotice.textContent = 319 formatDiagnosticMessage(workerDiagnostic) || 320 "The OCR/TTS worker is configured but not reachable yet. Check the Hugging Face Space URL, CORS_ORIGINS, and Space status, then redeploy."; 321 engineNotice.classList.add("warning"); 322 return; 323 } 324 325 if (cloudMode) { 326 engineNotice.textContent = 327 formatDiagnosticMessage(deploymentStatus.workerDiagnostic) || 328 deploymentAction || 329 "Downloadable Vercel audio needs the OCR/TTS worker. Add WORKER_BASE_URL in Vercel, then redeploy."; 330 engineNotice.classList.add("warning"); 331 return; 332 } 333 334 if (engines.readyForArabic) { 335 const label = engines.preferred === "piper" ? "Neural Arabic voice" : "Arabic voice"; 336 const installedArabicStack = engines.ocr?.arabicTrainedStack?.installed || installedArabicOcrModels; 337 const installedArabicText = installedArabicStack.length 338 ? ` Installed Arabic OCR: ${installedArabicStack.join(", ")}.` 339 : ""; 340 const ocrLabel = 341 engines.ocr?.preferred === "arabic-max" 342 ? "Maximum Arabic OCR is ready, but slower" 343 : engines.ocr?.preferred === "arabic" 344 ? "Arabic OCR comparison is ready, but slower" 345 : engines.ocr?.preferred === "qari-ocr" 346 ? "QARI Arabic book OCR is ready" 347 : engines.ocr?.preferred === "tawkeed-ocr" 348 ? "Tawkeed Arabic OCR is ready" 349 : engines.ocr?.preferred === "katib-ocr" 350 ? "KATIB Arabic OCR is ready" 351 : engines.ocr?.preferred === "arabic-qwen-ocr" 352 ? "Arabic-Qwen OCR is ready" 353 : engines.ocr?.preferred === "arabic-glm-ocr" 354 ? "Arabic-GLM OCR is ready" 355 : engines.ocr?.preferred === "baseer-ocr" 356 ? "Baseer Arabic OCR is ready" 357 : engines.ocr?.preferred === "paddleocr" 358 ? "PaddleOCR Arabic is ready, but less readable" 359 : engines.ocr?.preferred === "paddleocr-vl" 360 ? "PaddleOCR-VL heavy OCR is ready" 361 : engines.ocr?.preferred === "surya" 362 ? "Surya heavy OCR is ready" 363 : engines.ocr?.preferred === "tesseract" 364 ? "Rank 1 readable Tesseract Arabic OCR is ready" 365 : engines.ocr?.preferred === "tesseract-fast" 366 ? "Rank 2 faster Tesseract Arabic OCR is ready" 367 : engines.ocr?.preferred === "best" 368 ? "Best Arabic OCR test mode is ready" 369 : engines.ocr?.preferred 370 ? `${ocrModeLabels[engines.ocr.preferred] || "Arabic OCR"} is ready` 371 : "Arabic OCR is not ready"; 372 engineNotice.textContent = 373 `${label} is ready. ${ocrLabel}. Uploads up to ${payload.maxUploadMb} MB are accepted.${installedArabicText}`; 374 engineNotice.classList.remove("warning"); 375 return; 376 } 377 378 engineNotice.textContent = `A better Arabic voice is not installed yet. Upload limit: ${payload.maxUploadMb} MB.`; 379 engineNotice.classList.add("warning"); 380 } catch (error) { 381 engineNotice.textContent = "Unable to check the voice."; 382 engineNotice.classList.add("warning"); 383 } 384} 385 386async function loadWorkerDiagnostic(error) { 387 try { 388 const diagnostic = await fetch("/api/worker-diagnostics").then(readJson); 389 if (diagnostic?.message) return diagnostic; 390 } catch (_diagnosticError) { 391 // Keep the original worker error visible when the diagnostic endpoint cannot run. 392 } 393 return { 394 status: "browser-worker-error", 395 reachable: false, 396 workerBaseUrl, 397 message: `The OCR/TTS worker could not be reached from this browser. ${error.message}`, 398 }; 399} 400 401async function requireWorkerUnlock() { 402 pendingUnlockCode = ""; 403 sessionUnlockCode = ""; 404 remoteWorkerMode = false;
405 cloudMode = false; 406 await fetch("/api/logout", { method: "POST" }).catch(() => null); 407 setAuthenticated(false); 408 setError(loginError, "Enter the code once more to unlock the OCR worker."); 409 engineNotice.textContent = "The OCR worker needs to be unlocked."; 410 engineNotice.classList.add("warning"); 411} 412 413async function loginRemoteWorker() { 414 const code = pendingUnlockCode || sessionUnlockCode; 415 if (!remoteWorkerMode || !code) return false; 416 const formData = new FormData(); 417 formData.append("code", code); 418 try { 419 await fetch(`${workerBaseUrl}/api/login`, { 420 method: "POST", 421 body: formData, 422 credentials: "include", 423 }).then(readJson); 424 pendingUnlockCode = ""; 425 return true; 426 } catch (_error) { 427 return false; 428 } 429} 430 431async function ensureWorkerSessionForUpload() { 432 if (!remoteWorkerMode) return true; 433 try { 434 const session = await apiFetch("/api/session").then(readJson); 435 if (session.authenticated) return true; 436 } catch (_error) { 437 // Try a fresh login below before surfacing an upload-specific network message. 438 } 439 if (await loginRemoteWorker()) return true; 440 try { 441 const session = await apiFetch("/api/session").then(readJson); 442 return Boolean(session.authenticated); 443 } catch (_error) { 444 return false; 445 } 446} 447 448async function loadHistory() { 449 if (cloudMode && !remoteWorkerMode) { 450 historyPanel.classList.add("hidden"); 451 return; 452 } 453 try { 454 const payload = await apiFetch("/api/jobs").then(readJson); 455 const jobs = payload.jobs || []; 456 renderHistory(jobs); 457 resumeActiveJob(jobs); 458 } catch (error) { 459 historyPanel.classList.add("hidden"); 460 } 461} 462 463async function loadLibrary() { 464 try { 465 const payload = await apiFetch("/api/library").then(readJson); 466 const items = payload.items || []; 467 renderLibrary(items); 468 } catch (_error) { 469 libraryPanel.classList.add("hidden"); 470 } 471} 472 473function renderLibrary(items) { 474 libraryPanel.classList.toggle("hidden", items.length === 0); 475 libraryList.innerHTML = ""; 476 for (const item of items) { 477 const row = document.createElement("li"); 478 const details = document.createElement("div"); 479 const name = document.createElement("strong"); 480 const meta = document.createElement("span"); 481 name.textContent = item.filename || item.sampleId || item.id || "Stored audio"; 482 meta.textContent = [ 483 item.kind === "comparison" ? "Comparison sample" : "Audio job", 484 item.ocrLabel || item.ocrEngine, 485 item.voiceLabel || item.voiceId, 486 item.characters ? `${Number(item.characters).toLocaleString()} chars` : "", 487 item.createdAt ? new Date(item.createdAt).toLocaleString() : "", 488 ].filter(Boolean).join(" - "); 489 details.append(name, meta); 490 const actions = document.createElement("div"); 491 actions.className = "history-actions"; 492 if (item.audioUrl) { 493 const audioLink = document.createElement("a"); 494 audioLink.href = mediaUrl(item.audioUrl); 495 audioLink.textContent = "Audio"; 496 actions.append(audioLink); 497 } 498 if (item.transcriptUrl) { 499 const transcriptLink = document.createElement("a"); 500 transcriptLink.href = mediaUrl(item.transcriptUrl); 501 transcriptLink.textContent = "Transcript"; 502 actions.append(transcriptLink); 503 }
504 row.append(details); 505 if (actions.children.length) row.append(actions); 506 libraryList.append(row); 507 } 508} 509 510function renderHistory(jobs) { 511 const visibleJobs = jobs.filter((job) => job.filename); 512 historyPanel.classList.toggle("hidden", visibleJobs.length === 0); 513 historyList.innerHTML = ""; 514 for (const job of visibleJobs) { 515 const item = document.createElement("li"); 516 const details = document.createElement("div"); 517 const name = document.createElement("strong"); 518 const meta = document.createElement("span"); 519 name.textContent = job.filename; 520 meta.textContent = `${statusLabels[job.status] || job.status} - ${formatPages(job)}`; 521 if (job.pageLimit) { 522 meta.textContent += ` - ${pageLimitLabels[job.pageLimit] || `${job.pageLimit} pages`}`; 523 } 524 if (job.ttsSpeed && job.ttsSpeed !== 1) { 525 meta.textContent += ` - ${speedLabels[job.ttsSpeed] || `${job.ttsSpeed}x`}`; 526 } 527 if (job.ocrEngine && job.extraction && job.extraction !== "embedded") { 528 meta.textContent += ` - ${ocrModeLabels[job.ocrEngine] || job.ocrEngine}`; 529 } 530 if (job.textQuality) { 531 meta.textContent += ` - ${qualityLabels[job.textQuality] || job.textQuality}`; 532 } 533 if (job.audioFormat) { 534 meta.textContent += ` - ${job.audioFormat.toUpperCase()} ${formatBytes(job.audioBytes)}`; 535 } 536 details.append(name, meta);
537 item.append(details); 538 const actions = document.createElement("div"); 539 actions.className = "history-actions"; 540 if (["queued", "reading", "speaking"].includes(job.status)) { 541 const watchButton = document.createElement("button"); 542 watchButton.type = "button"; 543 watchButton.className = "link-button"; 544 watchButton.textContent = "Watch"; 545 watchButton.addEventListener("click", () => startPolling(job.id)); 546 actions.append(watchButton); 547 } 548 if (["failed", "complete"].includes(job.status)) { 549 const settingsButton = document.createElement("button"); 550 settingsButton.type = "button"; 551 settingsButton.className = "link-button"; 552 settingsButton.textContent = "Use settings"; 553 settingsButton.addEventListener("click", () => applyJobSettings(job)); 554 actions.append(settingsButton); 555 } 556 if (job.downloadUrl) { 557 const link = document.createElement("a"); 558 link.href = mediaUrl(job.downloadUrl); 559 link.textContent = "Download"; 560 actions.append(link); 561 } 562 if (actions.children.length) item.append(actions); 563 historyList.append(item); 564 } 565} 566 567function resumeActiveJob(jobs) { 568 if (activeJobId || pollTimer) return; 569 const activeJob = jobs.find((job) => ["queued", "reading", "speaking"].includes(job.status)); 570 if (activeJob) startPolling(activeJob.id); 571} 572 573function applyJobSettings(job) { 574 setSelectValue(voiceSelect, job.voiceId); 575 setSelectValue(speedSelect, String(job.ttsSpeed || 1)); 576 setSelectValue(ocrModeSelect, job.ocrEngine); 577 setSelectValue(pageLimitSelect, String(job.pageLimit || 0)); 578 describeOcrMode(); 579 setError(uploadError, `Settings loaded for ${job.filename || "that job"}. Choose the PDF again, then create audio.`); 580 pdfInput.focus(); 581} 582 583function setSelectValue(select, value) { 584 if (value === undefined || value === null || value === "") return; 585 const text = String(value); 586 if ([...select.options].some((option) => option.value === text)) { 587 select.value = text; 588 } 589} 590 591loginForm.addEventListener("submit", async (event) => { 592 event.preventDefault(); 593 setError(loginError, ""); 594 const formData = new FormData(); 595 pendingUnlockCode = codeInput.value.trim(); 596 sessionUnlockCode = pendingUnlockCode; 597 formData.append("code", pendingUnlockCode); 598 try { 599 await fetch("/api/login", { method: "POST", body: formData }).then(readJson); 600 codeInput.value = ""; 601 setAuthenticated(true); 602 } catch (error) { 603 setError(loginError, error.message); 604 } 605}); 606 607logoutButton.addEventListener("click", async () => { 608 if (remoteWorkerMode) { 609 await apiFetch("/api/logout", { method: "POST" }).catch(() => null); 610 } 611 await fetch("/api/logout", { method: "POST" }).then(readJson); 612 pendingUnlockCode = ""; 613 sessionUnlockCode = ""; 614 setAuthenticated(false); 615}); 616 617pdfInput.addEventListener("change", () => { 618 describeFile(pdfInput.files[0]); 619}); 620 621ocrModeSelect.addEventListener("change", () => { 622 describeOcrMode(); 623}); 624 625browserSpeakButton.addEventListener("click", () => { 626 speakBrowserSpeechText(); 627}); 628 629browserStopButton.addEventListener("click", () => { 630 if (canUseBrowserSpeech()) window.speechSynthesis.cancel(); 631 jobMessage.textContent = "Browser read-aloud stopped."; 632}); 633 634uploadForm.addEventListener("submit", async (event) => { 635 event.preventDefault(); 636 setError(uploadError, ""); 637 const file = pdfInput.files[0]; 638 if (!file) { 639 setError(uploadError, "Choose a PDF first."); 640 return; 641 } 642 if (!file.name.toLowerCase().endsWith(".pdf")) { 643 setError(uploadError, "Choose a PDF file."); 644 return; 645 } 646 if (maxUploadMb && file.size > maxUploadMb * 1024 * 1024) { 647 setError(uploadError, `This PDF is larger than ${maxUploadMb} MB.`); 648 return; 649 } 650 651 if (hostedShellMode && workerBaseUrl && !remoteWorkerMode) { 652 setError( 653 uploadError, 654 formatDiagnosticMessage(workerUnavailableDiagnostic) || 655 "The OCR/TTS worker is configured but not reachable. Open the Hugging Face Space, confirm it is running, set CORS_ORIGINS to this Vercel URL, then redeploy.", 656 ); 657 return; 658 } 659 660 if (cloudMode && !remoteWorkerMode) { 661 setError( 662 uploadError, 663 formatDiagnosticMessage( 664 workerUnavailableDiagnostic, 665 "This Vercel site needs WORKER_BASE_URL before it can create downloadable Arabic audio. Deploy the Hugging Face Space worker, set Vercel WORKER_BASE_URL to that https://*.hf.space URL, remove the temporary Hugging Face cloud TTS variables, then redeploy.", 666 ), 667 ); 668 updateStage({ 669 id: "upload", 670 label: "Worker needed", 671 phase: "Vercel setup needed", 672 detail: "Downloadable audio on Vercel is created by the OCR/TTS worker, not the temporary cloud voice fallback.", 673 progress: 0, 674 steps: defaultStageSteps.map((step) => ({ ...step, state: "pending" })), 675 }); 676 return; 677 } 678 679 await createLocalAudio(file); 680}); 681 682compareButton.addEventListener("click", async () => { 683 setError(uploadError, ""); 684 const file = pdfInput.files[0]; 685 if (!file) { 686 setError(uploadError, "Choose a PDF first."); 687 return; 688 } 689 if (!file.name.toLowerCase().endsWith(".pdf")) { 690 setError(uploadError, "Choose a PDF file."); 691 return; 692 } 693 if (maxUploadMb && file.size > maxUploadMb * 1024 * 1024) { 694 setError(uploadError, `This PDF is larger than ${maxUploadMb} MB.`); 695 return; 696 } 697 await createComparison(file); 698}); 699 700uploadForm.addEventListener("dragover", (event) => { 701 event.preventDefault(); 702 uploadForm.classList.add("is-dragging"); 703}); 704 705uploadForm.addEventListener("dragleave", () => { 706 uploadForm.classList.remove("is-dragging"); 707}); 708 709uploadForm.addEventListener("drop", (event) => { 710 event.preventDefault(); 711 uploadForm.classList.remove("is-dragging"); 712 const file = event.dataTransfer.files[0]; 713 if (!file) return; 714 const transfer = new DataTransfer(); 715 transfer.items.add(file); 716 pdfInput.files = transfer.files; 717 describeFile(file); 718}); 719 720async function createLocalAudio(file) { 721 const formData = new FormData(); 722 formData.append("pdf", file); 723 formData.append("voice_id", voiceSelect.value); 724 formData.append("tts_speed", speedSelect.value); 725 formData.append("ocr_engine", ocrModeSelect.value); 726 formData.append("page_limit", pageLimitSelect.value); 727 resetJob(file.name, "Uploading PDF."); 728 createButton.disabled = true; 729 compareButton.disabled = true; 730 try { 731 if (remoteWorkerMode && !(await ensureWorkerSessionForUpload())) { 732 throw new Error("Enter the code again so this browser can upload directly to the OCR/TTS worker."); 733 } 734 const payload = await apiFetch("/api/jobs", { method: "POST", body: formData }).then(readJson); 735 startPolling(payload.jobId); 736 } catch (error) { 737 setError(uploadError, describeNetworkError(error)); 738 updateProgress(0); 739 setJobState("failed"); 740 createButton.disabled = false;
741 compareButton.disabled = false; 742 } 743} 744 745async function createComparison(file) { 746 if (cloudMode && !remoteWorkerMode) { 747 setError(uploadError, "Comparison runs need the OCR/TTS worker. The browser-only cloud mode cannot run every OCR system."); 748 return; 749 } 750 const formData = new FormData(); 751 formData.append("pdf", file); 752 formData.append("page_limit", pageLimitSelect.value || "5"); 753 resetJob(file.name, "Uploading PDF for comparison."); 754 resetComparisonPanel(file.name); 755 createButton.disabled = true; 756 compareButton.disabled = true; 757 try { 758 if (remoteWorkerMode && !(await ensureWorkerSessionForUpload())) { 759 throw new Error("Enter the code again so this browser can upload directly to the OCR/TTS worker."); 760 } 761 const payload = await apiFetch("/api/comparisons", { method: "POST", body: formData }).then(readJson); 762 startComparisonPolling(payload.comparisonId); 763 } catch (error) { 764 setError(uploadError, describeNetworkError(error)); 765 createButton.disabled = false; 766 compareButton.disabled = false; 767 comparisonState.textContent = "Failed"; 768 comparisonMessage.textContent = describeNetworkError(error); 769 } 770} 771 772async function createCloudAudio(file) { 773 const browserSpeechAvailable = canUseBrowserSpeech(); 774 if (!cloudTtsReady && !browserSpeechAvailable) { 775 setError( 776 uploadError, 777 formatDiagnosticMessage( 778 workerUnavailableDiagnostic, 779 "Downloadable Vercel audio needs WORKER_BASE_URL. Browser read-aloud can be used for a short temporary test.", 780 ), 781 ); 782 return; 783 } 784 785 resetJob(file.name, "Reading PDF in this browser."); 786 createButton.disabled = true; 787 compareButton.disabled = true; 788 let speechTextForFallback = ""; 789 try { 790 const extracted = await extractPdfText(file, getSelectedPageLimit()); 791 const speechText = prepareTextForSpeech(extracted.text); 792 speechTextForFallback = speechText; 793 const chunks = chunkText(speechText, cloudTtsMaxChars); 794 const quality = assessTextQuality(extracted.text, speechText); 795 pagesValue.textContent = formatPages({ pages: extracted.pages, totalPages: extracted.totalPages }); 796 charactersValue.textContent = speechText.length.toLocaleString(); 797 engineValue.textContent = "cloud"; 798 extractionValue.textContent = extracted.extraction; 799 qualityValue.textContent = formatQuality(quality); 800 showQualityHint(quality); 801 802 if (!chunks.length || !quality.readyForTts) { 803 const reason = quality.reasons.length ? ` ${quality.reasons.join("; ")}.` : ""; 804 throw new Error(`Text quality is poor, so audio was not created.${reason}`); 805 } 806 807 if (!cloudTtsReady) { 808 showBrowserSpeechFallback( 809 speechText, 810 file.name, 811 formatDiagnosticMessage(workerUnavailableDiagnostic, "Downloadable Vercel audio needs WORKER_BASE_URL."), 812 ); 813 return; 814 } 815 816 audioFormatValue.textContent = "WAV"; 817 setJobState("speaking", "Creating"); 818 updateStage({ 819 id: "voice", 820 label: "Voice", 821 phase: "Creating voice", 822 detail: "Creating Arabic audio from the cleaned text.", 823 progress: progressRanges.voiceStart, 824 step: 4, 825 totalSteps: 5, 826 itemProgress: stageItem("Audio part", 0, chunks.length), 827 steps: stepStates("voice"), 828 }); 829 830 const audioBuffers = []; 831 for (let index = 0; index < chunks.length; index += 1) { 832 const label = `Creating audio part ${index + 1} of ${chunks.length}`; 833 jobMessage.textContent = label; 834 updateStage({ 835 id: "voice", 836 label: "Voice", 837 phase: "Creating audio parts", 838 detail: label, 839 progress: rangeProgress(progressRanges.voiceStart, progressRanges.voiceEnd, index + 1, chunks.length), 840 step: 4, 841 totalSteps: 5, 842 itemProgress: stageItem("Audio part", index + 1, chunks.length), 843 steps: stepStates("voice"), 844 }); 845 const response = await fetch("/api/cloud-tts", { 846 method: "POST", 847 headers: { "Content-Type": "application/json" }, 848 body: JSON.stringify({ text: chunks[index], voiceId: voiceSelect.value }), 849 }); 850 if (!response.ok) { 851 const payload = await response.json().catch(() => ({})); 852 throw new Error(payload.detail || "Cloud voice failed."); 853 } 854 audioBuffers.push(await response.arrayBuffer()); 855 } 856 857 const audioBlob = mergeWavBuffers(audioBuffers); 858 showAudio(audioBlob, file.name); 859 setJobState("complete"); 860 jobMessage.textContent = "Audio is ready.";
861 updateStage({ 862 id: "ready", 863 label: "Ready", 864 phase: "Audio ready", 865 detail: "Audio is ready to play or download.", 866 progress: 100, 867 step: 5, 868 totalSteps: 5, 869 steps: defaultStageSteps.map((step) => ({ ...step, state: "done" })), 870 }); 871 } catch (error) { 872 if (speechTextForFallback && canUseBrowserSpeech()) { 873 const fallbackMessage = formatDiagnosticMessage( 874 workerUnavailableDiagnostic, 875 `${error.message} Browser read-aloud is available now, but downloadable Vercel audio needs WORKER_BASE_URL.`, 876 ); 877 showBrowserSpeechFallback( 878 speechTextForFallback, 879 file.name, 880 fallbackMessage, 881 ); 882 setError(uploadError, fallbackMessage); 883 return; 884 } 885 setError(uploadError, error.message); 886 setJobState("failed"); 887 jobMessage.textContent = "Processing failed."; 888 updateStage({ 889 id: "ocr", 890 label: "Stopped", 891 phase: "Processing stopped", 892 detail: "Processing stopped before audio could be created.", 893 progress: 0, 894 steps: defaultStageSteps.map((step, index) => ({ 895 ...step, 896 state: index === 2 ? "failed" : index < 2 ? "done" : "pending", 897 })), 898 }); 899 } finally { 900 createButton.disabled = false; 901 compareButton.disabled = false; 902 } 903} 904 905async function extractPdfText(file, pageLimit = 0) { 906 const pdfjs = await getPdfJs(); 907 const data = await file.arrayBuffer(); 908 const document = await pdfjs.getDocument({ data }).promise; 909 const pageTexts = []; 910 const pagesToProcess = effectivePageCount(document.numPages, pageLimit); 911 for (let pageNumber = 1; pageNumber <= pagesToProcess; pageNumber += 1) { 912 const page = await document.getPage(pageNumber); 913 const content = await page.getTextContent(); 914 const pageText = cleanText(content.items.map((item) => item.str || "").join(" ")); 915 pageTexts.push(pageText); 916 pagesValue.textContent = pageNumber; 917 jobMessage.textContent = `Reading page ${pageNumber} of ${pagesToProcess}`; 918 updateStage({ 919 id: "text", 920 label: "Text scan", 921 phase: "Checking text layer", 922 detail: `Checking the PDF text layer: page ${pageNumber} of ${pagesToProcess}.`, 923 progress: rangeProgress(progressRanges.textStart, progressRanges.textEnd, pageNumber, pagesToProcess), 924 step: 2, 925 totalSteps: 5, 926 itemProgress: stageItem("PDF page", pageNumber, pagesToProcess), 927 steps: stepStates("text"), 928 }); 929 } 930 const embeddedText = cleanText(pageTexts.filter(Boolean).join("\n\n")); 931 if (embeddedText.length >= 20 && !shouldOcrMixedPdf(pageTexts)) { 932 return { pages: pagesToProcess, totalPages: document.numPages, text: embeddedText, extraction: "browser" }; 933 } 934 if (embeddedText.length >= 20) { 935 const ratio = embeddedTextMissingPageRatio(pageTexts); 936 jobMessage.textContent = `Embedded text is incomplete on ${Math.round(ratio * 100)}% of pages. Starting Arabic OCR.`; 937 } else { 938 jobMessage.textContent = "No text layer found. Starting Arabic OCR."; 939 } 940 const ocrText = await ocrPdfText(document, pagesToProcess); 941 return { pages: pagesToProcess, totalPages: document.numPages, text: cleanText(ocrText), extraction: "ocr" }; 942} 943 944function embeddedTextMissingPageRatio(pageTexts) { 945 if (!pageTexts.length) return 1; 946 const missingPages = pageTexts.filter((text) => text.length < 20).length; 947 return missingPages / pageTexts.length; 948} 949 950function shouldOcrMixedPdf(pageTexts) { 951 if (!pageTexts.length) return true; 952 if (!pageTexts.some((text) => text.length >= 20)) return true; 953 return embeddedTextMissingPageRatio(pageTexts) > mixedPdfOcrMissingPageRatio; 954} 955 956async function ocrPdfText(document, pagesToProcess) { 957 const worker = await getOcrWorker(); 958 const pieces = []; 959 for (let pageNumber = 1; pageNumber <= pagesToProcess; pageNumber += 1) { 960 browserOcrProgress = { page: pageNumber, total: pagesToProcess }; 961 jobMessage.textContent = `OCR reading page ${pageNumber} of ${pagesToProcess}`; 962 updateStage({ 963 id: "ocr", 964 label: "Arabic OCR", 965 phase: "Rendering scanned pages", 966 detail: `Preparing scanned page ${pageNumber} of ${pagesToProcess}.`, 967 progress: rangeProgress(progressRanges.ocrStart, progressRanges.ocrEnd, pageNumber - 1, pagesToProcess), 968 step: 3, 969 totalSteps: 5, 970 itemProgress: stageItem("Scanned page", pageNumber - 1, pagesToProcess), 971 steps: stepStates("ocr"), 972 }); 973 const imageUrl = await renderPageForOcr(document, pageNumber); 974 const result = await withTesseractNoiseFilter(() => worker.recognize(imageUrl)); 975 URL.revokeObjectURL(imageUrl); 976 if (result.data.text.trim()) pieces.push(result.data.text); 977 updateStage({ 978 id: "ocr", 979 label: "Arabic OCR", 980 phase: "Reading scanned pages", 981 detail: `Finished scanned page ${pageNumber} of ${pagesToProcess}.`, 982 progress: rangeProgress(progressRanges.ocrStart, progressRanges.ocrEnd, pageNumber, pagesToProcess), 983 step: 3, 984 totalSteps: 5, 985 itemProgress: stageItem("Scanned page", pageNumber, pagesToProcess), 986 steps: stepStates("ocr"), 987 }); 988 } 989 return pieces.join("\n\n"); 990} 991 992function effectivePageCount(totalPages, pageLimit) { 993 return pageLimit > 0 ? Math.max(0, Math.min(totalPages, pageLimit)) : totalPages; 994} 995 996function getSelectedPageLimit() { 997 return Number.parseInt(pageLimitSelect.value, 10) || 0; 998} 999 1000async function getOcrWorker() { 1001 if (ocrWorker) return ocrWorker; 1002 jobMessage.textContent = "Loading Arabic OCR. This can take a minute the first time."; 1003 updateStage({ 1004 id: "ocr", 1005 label: "Arabic OCR", 1006 phase: "Loading Arabic OCR", 1007 detail: "Loading Arabic OCR files. This can take a minute the first time.", 1008 progress: progressRanges.ocrStart, 1009 step: 3, 1010 totalSteps: 5, 1011 steps: stepStates("ocr"), 1012 }); 1013 const createWorker = await getTesseractCreateWorker(); 1014 ocrWorker = await createWorker("ara", 1, { 1015 langPath: "https://tessdata.projectnaptha.com/4.0.0", 1016 workerPath: "https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/worker.min.js", 1017 corePath: "https://cdn.jsdelivr.net/npm/tesseract.js-core@5", 1018 logger: (event) => { 1019 if (event.status === "recognizing text" && event.progress) { 1020 const percent = Math.round(event.progress * 100); 1021 const page = browserOcrProgress.page || 1; 1022 const total = browserOcrProgress.total || 1; 1023 jobMessage.textContent = `OCR reading page ${page} of ${total}: ${percent}%`; 1024 updateStage({ 1025 id: "ocr", 1026 label: "Arabic OCR", 1027 phase: "Reading scanned pages", 1028 detail: `Reading scanned page ${page} of ${total}: ${percent}%.`, 1029 progress: rangeProgress(progressRanges.ocrStart, progressRanges.ocrEnd, page - 1 + event.progress, total), 1030 step: 3, 1031 totalSteps: 5, 1032 itemProgress: stageItem("Scanned page", page - 1 + event.progress, total), 1033 steps: stepStates("ocr"), 1034 }); 1035 } 1036 }, 1037 }); 1038 if (typeof ocrWorker.setParameters === "function") { 1039 await ocrWorker.setParameters({ 1040 tessedit_pageseg_mode: "6", 1041 preserve_interword_spaces: "1", 1042 }); 1043 } 1044 return ocrWorker; 1045} 1046 1047async function withTesseractNoiseFilter(callback) { 1048 const originalWarn = console.warn; 1049 const originalError = console.error; 1050 const isKnownNoise = (message) => 1051 message.includes("Image too small to scale") || 1052 message.includes("Line cannot be recognized"); 1053 const filter = (original) => (...args) => { 1054 const message = args.map((arg) => String(arg)).join(" "); 1055 if (isKnownNoise(message)) return; 1056 original(...args); 1057 }; 1058 console.warn = filter(originalWarn); 1059 console.error = filter(originalError); 1060 try { 1061 return await callback(); 1062 } finally { 1063 console.warn = originalWarn; 1064 console.error = originalError; 1065 } 1066} 1067 1068async function getPdfJs() { 1069 if (pdfjsLib) return pdfjsLib; 1070 pdfjsLib = await import("https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.10.38/pdf.min.mjs"); 1071 pdfjsLib.GlobalWorkerOptions.workerSrc = 1072 "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.10.38/pdf.worker.min.mjs"; 1073 return pdfjsLib; 1074} 1075 1076async function getTesseractCreateWorker() { 1077 if (tesseractCreateWorker) return tesseractCreateWorker; 1078 const tesseract = await import( 1079 "https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.esm.min.js" 1080 ); 1081 tesseractCreateWorker = tesseract.createWorker || tesseract.default?.createWorker; 1082 if (!tesseractCreateWorker) { 1083 throw new Error("Arabic OCR could not load. Try the local version for scanned PDFs."); 1084 } 1085 return tesseractCreateWorker; 1086} 1087 1088async function renderPageForOcr(document, pageNumber) { 1089 const page = await document.getPage(pageNumber); 1090 const viewport = page.getViewport({ scale: 2 }); 1091 const canvas = documentCanvas(viewport.width, viewport.height); 1092 const context = canvas.getContext("2d"); 1093 await page.render({ canvasContext: context, viewport }).promise; 1094 return await canvasToObjectUrl(canvas); 1095} 1096 1097function documentCanvas(width, height) { 1098 const canvas = document.createElement("canvas"); 1099 canvas.width = Math.ceil(width);
1100 canvas.height = Math.ceil(height); 1101 return canvas; 1102} 1103 1104function canvasToObjectUrl(canvas) { 1105 return new Promise((resolve, reject) => { 1106 canvas.toBlob((blob) => { 1107 if (!blob) { 1108 reject(new Error("Could not render PDF page for OCR.")); 1109 return; 1110 } 1111 resolve(URL.createObjectURL(blob)); 1112 }, "image/png"); 1113 }); 1114} 1115 1116function cleanText(text) { 1117 return text 1118 .normalize("NFKC") 1119 .replace(/[\u200e\u200f]/g, " ") 1120 .replace(/[\t\r\f\v]+/g, " ") 1121 .replace(/\n{3,}/g, "\n\n") 1122 .replace(/[ ]{2,}/g, " ") 1123 .trim(); 1124} 1125 1126function prepareTextForSpeech(text) { 1127 const cleaned = cleanText(text); 1128 const rawLines = cleaned.split(/\n/).map((line) => line.trim()); 1129 const counts = new Map(); 1130 for (const line of rawLines) { 1131 if (!line) continue; 1132 counts.set(line, (counts.get(line) || 0) + 1); 1133 } 1134 const repeatedLines = new Set( 1135 [...counts.entries()].filter(([line, count]) => count >= 3 && line.length <= 48).map(([line]) => line), 1136 ); 1137 const output = []; 1138 let previousLine = ""; 1139 let blankPending = false; 1140 for (const line of rawLines) { 1141 if (!line) { 1142 blankPending = output.length > 0; 1143 continue; 1144 } 1145 if (line === previousLine) continue; 1146 previousLine = line; 1147 if (shouldDropSpeechLine(line, repeatedLines)) continue; 1148 if (blankPending && output.length && output[output.length - 1] !== "") output.push(""); 1149 output.push(line); 1150 blankPending = false; 1151 } 1152 return output.join("\n").replace(/\n{3,}/g, "\n\n").trim(); 1153} 1154 1155function shouldDropSpeechLine(line, repeatedLines) { 1156 const compact = line.trim(); 1157 if (!compact) return true; 1158 if (/^[\s\-ââ_.:|/\\()[\]{}]*(?:[0-9Ù -Ù©Û°-Û¹]+|[ivxlcdmIVXLCDM]+)[\s\-ââ_.:|/\\()[\]{}]*$/.test(compact)) { 1159 return true; 1160 } 1161 const metrics = lineNoiseMetrics(compact); 1162 if (repeatedLines.has(compact)) return true; 1163 if ( 1164 !metrics.arabicWords && 1165 compact.length <= 80 && 1166 (compact.length <= 24 || metrics.digits >= 3 || metrics.symbols >= 2 || metrics.latinWords) 1167 ) { 1168 return true; 1169 } 1170 if (compact.length <= 2 && !metrics.arabicWords) return true; 1171 if (metrics.digits >= 4 && metrics.arabicWords <= 3) return true; 1172 if (metrics.digits >= 6 && metrics.digits > metrics.arabicChars) return true; 1173 if (metrics.symbols >= 3 && metrics.arabicWords <= 4) return true; 1174 if (metrics.placeholders >= 2 && metrics.arabicWords <= 4) return true; 1175 return false; 1176} 1177 1178function lineNoiseMetrics(line) { 1179 const arabicWords = line.match(/[\u0600-\u06ff\ufb50-\ufdff\ufe70-\ufeff]+/g) || []; 1180 const digits = line.match(/[0-9\u0660-\u0669\u06f0-\u06f9]/g) || []; 1181 const symbols = line.match(/[!@#$%^&*_+=<>|~`]/g) || []; 1182 const placeholders = line.match(/[?\ufffd]/g) || []; 1183 const latinWords = line.match(/[A-Za-z]{3,}/g) || []; 1184 return { 1185 arabicWords: arabicWords.length, 1186 arabicChars: arabicWords.reduce((total, word) => total + word.length, 0), 1187 digits: digits.length, 1188 symbols: symbols.length, 1189 placeholders: placeholders.length, 1190 latinWords: latinWords.length, 1191 }; 1192} 1193 1194function scoreTextQuality(text) { 1195 const speechText = prepareTextForSpeech(text); 1196 const arabicWords = speechText.match(/[\u0600-\u06ff\ufb50-\ufdff\ufe70-\ufeff]+/g) || []; 1197 const placeholderCount = (speechText.match(/[?\ufffd]/g) || []).length; 1198 const latinWords = speechText.match(/[A-Za-z]{3,}/g) || []; 1199 const digitNoise = (speechText.match(/[0-9\u0660-\u0669\u06f0-\u06f9]/g) || []).length; 1200 const lines = speechText.split(/\n/).map((line) => line.trim()).filter(Boolean); 1201 const metrics = lines.map((line) => lineNoiseMetrics(line)); 1202 const fragmentLines = metrics.filter((item, index) => ( 1203 item.arabicWords <= 2 && item.arabicChars <= 18 && lines[index].length <= 28 1204 )).length; 1205 const singleArabicWords = arabicWords.filter((word) => word.length === 1).length; 1206 const singleArabicWordRatio = singleArabicWords / Math.max(arabicWords.length, 1); 1207 const fragmentLineRatio = fragmentLines / Math.max(lines.length, 1); 1208 const shortLines = lines.filter((line) => line.length <= 3).length; 1209 const repeatedLines = lines.length - new Set(lines).size; 1210 const commonWords = new Set(["ÙÙ", "Ù Ù", "عÙÙ", "ÙØ°Ø§", "ÙØ°Ù", "Ø§ÙØªÙ", "Ø§ÙØ°Ù", "ÙØ§Ù", "Ø¥ÙÙ", "اÙÙ", "عÙ", "٠ع", "ÙÙ", "ÙÙ"]); 1211 const commonHits = arabicWords.filter((word) => commonWords.has(word)).length; 1212 const score = 1213 speechText.length * 0.05 + 1214 arabicWords.length * 3 + 1215 commonHits * 18 - 1216 placeholderCount * 25 - 1217 shortLines * 8 - 1218 repeatedLines * 6 - 1219 latinWords.length * 4 - 1220 digitNoise * 3 - 1221 singleArabicWords * 6 - 1222 fragmentLines * 14; 1223 return { 1224 score, 1225 speechText, 1226 arabicWords, 1227 placeholderCount, 1228 latinWords, 1229 digitNoise, 1230 singleArabicWords, 1231 singleArabicWordRatio, 1232 fragmentLines, 1233 fragmentLineRatio, 1234 }; 1235} 1236
1237function assessTextQuality(text, speechText = prepareTextForSpeech(text)) { 1238 const scored = scoreTextQuality(speechText); 1239 const placeholderRatio = scored.placeholderCount / Math.max(speechText.length, 1); 1240 const reasons = []; 1241 if (speechText.length < 20) reasons.push("too little readable text after cleanup"); 1242 if (scored.arabicWords.length < 5) reasons.push("too few Arabic words"); 1243 if (placeholderRatio >= 0.2) reasons.push("too many unreadable placeholder characters"); 1244 else if (placeholderRatio > 0) reasons.push("some unreadable placeholder characters remain"); 1245 if (scored.digitNoise >= Math.max(20, scored.arabicWords.length)) reasons.push("digit-heavy OCR noise remains"); 1246 if (scored.singleArabicWordRatio >= 0.1 && scored.arabicWords.length >= 25) { 1247 reasons.push("many one-letter Arabic OCR fragments remain"); 1248 } 1249 if (scored.fragmentLineRatio >= 0.25 && speechText.split(/\n/).length >= 8) { 1250 reasons.push("many low-information OCR lines remain"); 1251 } 1252 if (scored.latinWords.length >= 3 && scored.latinWords.length >= scored.arabicWords.length) { 1253 reasons.push("non-Arabic OCR text dominates"); 1254 } 1255 1256 const blockingReasons = new Set([ 1257 "too little readable text after cleanup", 1258 "too few Arabic words", 1259 "too many unreadable placeholder characters", 1260 "non-Arabic OCR text dominates", 1261 ]); 1262 const quality = reasons.some((reason) => blockingReasons.has(reason)) 1263 ? "poor" 1264 : reasons.length 1265 ? "warning" 1266 : "good"; 1267 return { 1268 quality, 1269 readyForTts: quality !== "poor", 1270 reasons, 1271 score: Math.round(scored.score * 100) / 100, 1272 singleArabicWordRatio: Math.round(scored.singleArabicWordRatio * 10000) / 10000, 1273 fragmentLineRatio: Math.round(scored.fragmentLineRatio * 10000) / 10000, 1274 }; 1275} 1276 1277function formatQuality(quality) { 1278 if (!quality?.quality) return "-"; 1279 const label = qualityLabels[quality.quality] || quality.quality; 1280 return `${label} ${quality.score ? Math.round(quality.score) : ""}`.trim(); 1281} 1282 1283function splitLongTextAtWordBoundaries(text, size) { 1284 const pieces = []; 1285 let remaining = text.trim(); 1286 while (remaining.length > size) { 1287 let splitAt = remaining.lastIndexOf(" ", size); 1288 if (splitAt < Math.max(1, Math.floor(size * 0.45))) splitAt = size; 1289 const piece = remaining.slice(0, splitAt).trim(); 1290 if (piece) pieces.push(piece); 1291 remaining = remaining.slice(splitAt).trim(); 1292 } 1293 if (remaining) pieces.push(remaining); 1294 return pieces; 1295} 1296 1297function chunkText(text, size) { 1298 text = prepareTextForSpeech(text); 1299 const paragraphs = text.split(/\n{2,}/).map((part) => part.trim()).filter(Boolean); 1300 const chunks = []; 1301 let current = ""; 1302 for (const paragraph of paragraphs) { 1303 if ((current.length + paragraph.length + 2) <= size) { 1304 current = `${current}\n\n${paragraph}`.trim(); 1305 continue; 1306 } 1307 if (current) chunks.push(current); 1308 if (paragraph.length <= size) { 1309 current = paragraph; 1310 continue; 1311 } 1312 const sentences = paragraph.split(/(?<=[.!\u061f?\u060c\u061b])\s+/); 1313 current = ""; 1314 for (const sentence of sentences) { 1315 let remaining = sentence; 1316 if ((current.length + remaining.length + 1) <= size) { 1317 current = `${current} ${remaining}`.trim(); 1318 } else { 1319 if (current) chunks.push(current); 1320 const sentenceParts = splitLongTextAtWordBoundaries(remaining, size); 1321 chunks.push(...sentenceParts.slice(0, -1)); 1322 current = sentenceParts[sentenceParts.length - 1] || ""; 1323 } 1324 } 1325 } 1326 if (current) chunks.push(current); 1327 return chunks; 1328} 1329 1330function mergeWavBuffers(buffers) { 1331 if (buffers.length === 1) return new Blob([buffers[0]], { type: "audio/wav" }); 1332 const wavs = buffers.map(parseWav); 1333 const fmt = wavs[0].fmt; 1334 const dataLength = wavs.reduce((total, wav) => total + wav.data.length, 0); 1335 const output = new ArrayBuffer(12 + 8 + fmt.length + 8 + dataLength); 1336 const view = new DataView(output); 1337 const bytes = new Uint8Array(output); 1338 let offset = 0; 1339 1340 offset = writeString(bytes, offset, "RIFF"); 1341 view.setUint32(offset, output.byteLength - 8, true); 1342 offset += 4; 1343 offset = writeString(bytes, offset, "WAVE"); 1344 offset = writeString(bytes, offset, "fmt "); 1345 view.setUint32(offset, fmt.length, true); 1346 offset += 4; 1347 bytes.set(fmt, offset); 1348 offset += fmt.length; 1349 offset = writeString(bytes, offset, "data"); 1350 view.setUint32(offset, dataLength, true); 1351 offset += 4; 1352 for (const wav of wavs) { 1353 bytes.set(wav.data, offset); 1354 offset += wav.data.length; 1355 } 1356 return new Blob([output], { type: "audio/wav" }); 1357} 1358 1359function parseWav(buffer) { 1360 const view = new DataView(buffer); 1361 const bytes = new Uint8Array(buffer); 1362 if (readString(bytes, 0, 4) !== "RIFF" || readString(bytes, 8, 4) !== "WAVE") { 1363 throw new Error("Cloud voice returned an audio format that cannot be merged."); 1364 } 1365 let offset = 12; 1366 let fmt = null; 1367 let data = null; 1368 while (offset + 8 <= bytes.length) { 1369 const id = readString(bytes, offset, 4); 1370 const length = view.getUint32(offset + 4, true); 1371 const start = offset + 8; 1372 const end = start + length; 1373 if (id === "fmt ") fmt = bytes.slice(start, end); 1374 if (id === "data") data = bytes.slice(start, end); 1375 offset = end + (length % 2); 1376 } 1377 if (!fmt || !data) throw new Error("Cloud voice returned invali
1377d WAV audio."); 1378 return { fmt, data }; 1379} 1380 1381function readString(bytes, offset, length) { 1382 return String.fromCharCode(...bytes.slice(offset, offset + length)); 1383} 1384 1385function writeString(bytes, offset, value) { 1386 for (let index = 0; index < value.length; index += 1) { 1387 bytes[offset + index] = value.charCodeAt(index); 1388 } 1389 return offset + value.length; 1390} 1391 1392function showAudio(blob, sourceName) { 1393 resetBrowserSpeechFallback(); 1394 if (currentAudioUrl) URL.revokeObjectURL(currentAudioUrl); 1395 currentAudioUrl = URL.createObjectURL(blob); 1396 audioPlayer.src = currentAudioUrl; 1397 downloadLink.href = currentAudioUrl; 1398 downloadLink.download = `${sourceName.replace(/\.pdf$/i, "") || "arabic-pdf"}.wav`; 1399 audioPanel.classList.remove("hidden"); 1400} 1401 1402function resetComparisonPanel(sourceName = "Arabic PDF") { 1403 activeComparisonId = ""; 1404 clearInterval(comparisonPollTimer); 1405 comparisonPollTimer = null; 1406 comparisonTitle.textContent = `${sourceName.replace(/\.pdf$/i, "") || "PDF"} comparison`; 1407 comparisonState.textContent = "Queued"; 1408 comparisonMessage.textContent = "OCR will run once per system, then each transcript will be sent to every voice."; 1409 comparisonBar.style.width = "0%"; 1410 comparisonResults.innerHTML = ""; 1411 comparisonPanel.classList.remove("hidden"); 1412} 1413 1414function startComparisonPolling(comparisonId) { 1415 activeComparisonId = comparisonId; 1416 clearInterval(comparisonPollTimer); 1417 comparisonPollTimer = setInterval(() => pollComparison(comparisonId), 1200); 1418 pollComparison(comparisonId); 1419} 1420 1421async function pollComparison(comparisonId) { 1422 try { 1423 const comparison = await apiFetch(`/api/comparisons/${comparisonId}`).then(readJson); 1424 renderComparison(comparison); 1425 if (["complete", "failed"].includes(comparison.status)) { 1426 clearInterval(comparisonPollTimer); 1427 comparisonPollTimer = null; 1428 activeComparisonId = null; 1429 createButton.disabled = false; 1430 compareButton.disabled = false; 1431 loadHistory(); 1432 loadLibrary(); 1433 } 1434 } catch (error) { 1435 clearInterval(comparisonPollTimer); 1436 comparisonPollTimer = null; 1437 activeComparisonId = null; 1438 createButton.disabled = false; 1439 compareButton.disabled = false; 1440 comparisonState.textContent = "Failed"; 1441 comparisonMessage.textContent = error?.message || "Comparison failed."; 1442 } 1443} 1444 1445function renderComparison(comparison) { 1446 comparisonPanel.classList.remove("hidden"); 1447 comparisonTitle.textContent = `${comparison.filename || "PDF"} comparison`; 1448 comparisonState.textContent = statusLabels[comparison.status] || comparison.status; 1449 comparisonMessage.textContent = comparison.error || comparison.message || "Running comparison."; 1450 comparisonBar.style.width = `${Math.max(0, Math.min(100, Number(comparison.progress) || 0))}%`; 1451 comparisonResults.innerHTML = ""; 1452 const results = comparison.results || []; 1453 if (!results.length) { 1454 const empty = document.createElement("p"); 1455 empty.className = "comparison-empty"; 1456 empty.textContent = "Waiting for the first OCR result."; 1457 comparisonResults.append(empty); 1458 return; 1459 } 1460 const groups = new Map(); 1461 for (const result of results) { 1462 const key = result.ocrEngine || "unknown"; 1463 if (!groups.has(key)) groups.set(key, { ocr: null, voices: [] }); 1464 if (result.type === "ocr") groups.get(key).ocr = result; 1465 if (result.type === "voice") groups.get(key).voices.push(result); 1466 } 1467 for (const group of groups.values()) { 1468 const section = document.createElement("section"); 1469 section.className = "comparison-group"; 1470 const ocr = group.ocr || {}; 1471 const heading = document.createElement("div"); 1472 heading.className = "comparison-group-heading"; 1473 const title = document.createElement("h4"); 1474 title.textContent = ocr.ocrLabel || ocr.ocrEngine || "OCR"; 1475 const meta = document.createElement("span"); 1476 const quality = ocr.textQuality ? `${qualityLabels[ocr.textQuality] || ocr.textQuality} ${Math.round(Number(ocr.qualityScore) || 0)}` : ocr.status || ""; 1477 meta.textContent = [quality, ocr.characters ? `${Number(ocr.characters).toLocaleString()} chars` : "", ocr.extraction || ""] 1478 .filter(Boolean) 1479 .join(" - "); 1480 heading.append(title, meta); 1481 section.append(heading); 1482 if (ocr.transcriptPreview) { 1483 const preview = document.createElement("pre"); 1484 preview.dir = "rtl";
1485 preview.lang = "ar"; 1486 preview.textContent = ocr.transcriptPreview; 1487 section.append(preview); 1488 } 1489 if (ocr.error) { 1490 const error = document.createElement("p"); 1491 error.className = "comparison-error"; 1492 error.textContent = ocr.error; 1493 section.append(error); 1494 } 1495 const sampleList = document.createElement("div"); 1496 sampleList.className = "comparison-samples"; 1497 for (const sample of group.voices) { 1498 const card = document.createElement("article"); 1499 card.className = `comparison-sample ${sample.status || ""}`; 1500 const sampleTitle = document.createElement("strong"); 1501 sampleTitle.textContent = sample.voiceLabel || sample.voiceId; 1502 const sampleMeta = document.createElement("span"); 1503 sampleMeta.textContent = [ 1504 sample.status, 1505 sample.engine, 1506 sample.chunks ? `${sample.chunks} parts` : "", 1507 sample.elapsedSeconds ? `${sample.elapsedSeconds}s` : "", 1508 sample.audioBytes ? formatBytes(sample.audioBytes) : "", 1509 ].filter(Boolean).join(" - "); 1510 card.append(sampleTitle, sampleMeta); 1511 if (sample.audioUrl) { 1512 const audio = document.createElement("audio"); 1513 audio.controls = true; 1514 audio.src = mediaUrl(sample.audioUrl); 1515 const link = document.createElement("a"); 1516 link.href = mediaUrl(sample.downloadUrl || sample.audioUrl); 1517 link.textContent = "Download"; 1518 card.append(audio, link); 1519 } 1520 if (sample.error) { 1521 const error = document.createElement("small"); 1522 error.textContent = sample.error; 1523 card.append(error); 1524 } 1525 sampleList.append(card); 1526 } 1527 section.append(sampleList); 1528 comparisonResults.append(section); 1529 } 1530} 1531 1532function resetTranscriptPanel() { 1533 activeTranscriptUrl = ""; 1534 transcriptText.textContent = ""; 1535 transcriptMeta.textContent = "Transcript appears here after OCR and Gemini cleanup."; 1536 transcriptPanel.classList.add("hidden"); 1537} 1538 1539function updateTranscriptPanel(job) { 1540 const preview = job?.transcriptPreview || ""; 1541 activeTranscriptUrl = job?.transcriptUrl || ""; 1542 if (!preview && !activeTranscriptUrl) { 1543 resetTranscriptPanel(); 1544 return; 1545 } 1546 transcriptPanel.classList.remove("hidden"); 1547 transcriptText.textContent = preview || "Transcript is ready. Use Refresh to load it."; 1548 const count = Number(job?.transcriptCharacters || preview.length || 0).toLocaleString(); 1549 transcriptMeta.textContent = job?.transcriptTruncated 1550 ? `${count} characters. Showing a preview; refresh loads the full cleaned transcript.` 1551 : `${count} characters after OCR/Gemini cleanup.`; 1552} 1553 1554async function loadFullTranscript() { 1555 if (!activeTranscriptUrl) return; 1556 transcriptRefreshButton.disabled = true; 1557 transcriptRefreshButton.textContent = "Loading"; 1558 try { 1559 const response = await apiFetch(activeTranscriptUrl); 1560 if (!response.ok) throw new Error("Transcript is not ready yet."); 1561 const text = await response.text(); 1562 transcriptText.textContent = text || "Transcript is empty."; 1563 transcriptMeta.textContent = `${text.length.toLocaleString()} characters loaded from the cleaned transcript.`; 1564 } catch (error) { 1565 transcriptMeta.textContent = error?.message || "Unable to load the transcript yet."; 1566 } finally { 1567 transcriptRefreshButton.disabled = false; 1568 transcriptRefreshButton.textContent = "Refresh"; 1569 } 1570} 1571 1572function canUseBrowserSpeech() { 1573 return "speechSynthesis" in window && typeof window.SpeechSynthesisUtterance === "function"; 1574} 1575 1576function resetBrowserSpeechFallback() { 1577 if (canUseBrowserSpeech()) window.speechSynthesis.cancel(); 1578 browserSpeechText = ""; 1579 browserSpeechSourceName = ""; 1580 browserSpeechPanel.classList.add("hidden"); 1581 browserSpeechMessage.textContent = "Browser read-aloud is ready."; 1582} 1583 1584function showBrowserSpeechFallback(text, sourceName, message) { 1585 browserSpeechText = text; 1586 browserSpeechSourceName = sourceName; 1587 audioPanel.classList.add("hidden"); 1588 audioPlayer.removeAttribute("src"); 1589 downloadLink.removeAttribute("href"); 1590 downloadLink.removeAttribute("download"); 1591 audioFormatValue.textContent = "Browser only"; 1592 engineValue.textContent = "browser voice"; 1593 setJobState("speaking", "Listen only"); 1594 jobMessage.textContent = "Browser read-aloud is ready.";
1595 browserSpeechMessage.textContent = message; 1596 browserSpeechPanel.classList.remove("hidden"); 1597 updateStage({ 1598 id: "ready", 1599 label: "Listen", 1600 phase: "Browser read-aloud ready", 1601 detail: "Downloadable audio needs the Vercel worker. Browser read-aloud is ready for listening.", 1602 progress: 100, 1603 step: 5, 1604 totalSteps: 5, 1605 steps: defaultStageSteps.map((step) => ({ ...step, state: "done" })), 1606 }); 1607} 1608 1609function getBrowserArabicVoice() { 1610 if (!canUseBrowserSpeech()) return null; 1611 const voices = window.speechSynthesis.getVoices(); 1612 return ( 1613 voices.find((voice) => voice.lang?.toLowerCase().startsWith("ar")) || 1614 voices.find((voice) => /arabic|ar[-_]/i.test(`${voice.name} ${voice.lang}`)) || 1615 null 1616 ); 1617} 1618 1619function speakBrowserSpeechText() { 1620 if (!browserSpeechText || !canUseBrowserSpeech()) return; 1621 const chunks = chunkText(browserSpeechText, 700); 1622 if (!chunks.length) return; 1623 const synth = window.speechSynthesis; 1624 const voice = getBrowserArabicVoice(); 1625 const rate = Number(speedSelect.value) || 1; 1626 let index = 0; 1627 synth.cancel(); 1628 1629 const speakNext = () => { 1630 if (index >= chunks.length) { 1631 jobMessage.textContent = "Browser read-aloud finished."; 1632 return; 1633 } 1634 const utterance = new window.SpeechSynthesisUtterance(chunks[index]); 1635 utterance.lang = voice?.lang || "ar"; 1636 utterance.voice = voice; 1637 utterance.rate = Math.max(0.6, Math.min(1.35, rate)); 1638 utterance.onend = () => { 1639 index += 1; 1640 const nextPart = Math.min(index + 1, chunks.length); 1641 updateStage({ 1642 id: "voice", 1643 label: "Listening", 1644 phase: "Browser read-aloud", 1645 detail: index >= chunks.length ? "Browser read-aloud finished." : `Reading part ${nextPart} of ${chunks.length}.`, 1646 progress: rangeProgress(progressRanges.voiceStart, 100, index, chunks.length), 1647 step: 5, 1648 totalSteps: 5, 1649 itemProgress: stageItem("Read-aloud part", index, chunks.length), 1650 steps: defaultStageSteps.map((step) => ({ ...step, state: "done" })), 1651 }); 1652 speakNext(); 1653 }; 1654 utterance.onerror = () => { 1655 jobMessage.textContent = "Browser read-aloud stopped."; 1656 }; 1657 jobMessage.textContent = `Reading ${browserSpeechSourceName || "PDF"} in the browser.`; 1658 synth.speak(utterance); 1659 }; 1660 1661 updateStage({ 1662 id: "voice", 1663 label: "Listening", 1664 phase: "Browser read-aloud", 1665 detail: `Reading part 1 of ${chunks.length}.`, 1666 progress: progressRanges.voiceStart, 1667 step: 5, 1668 totalSteps: 5, 1669 itemProgress: stageItem("Read-aloud part", 0, chunks.length), 1670 steps: defaultStageSteps.map((step) => ({ ...step, state: "done" })), 1671 }); 1672 speakNext(); 1673} 1674 1675function describeFile(file) { 1676 fileName.textContent = file ? `${file.name} (${Math.ceil(file.size / 1024 / 1024)} MB)` : "No file selected"; 1677} 1678 1679function describeOcrMode() { 1680 const installedText = installedArabicOcrModels.length 1681 ? ` Installed now: ${installedArabicOcrModels.join(", ")}.` 1682 : " Install QARI, Tawkeed, or KATIB on the worker for the best Arabic-trained OCR."; 1683 if (ocrModeSelect.value === "arabic-max") { 1684 engineNotice.textContent = `Maximum Arabic OCR selected. It tries the most engines and keeps the cleanest text, but it is slower. Use Quick test first.${installedText}`; 1685 engineNotice.classList.remove("warning"); 1686 } else if (ocrModeSelect.value === "arabic") { 1687 engineNotice.textContent = `Arabic OCR comparison selected. It compares installed OCR paths and can be much slower than the recommended Tesseract setting.${installedText}`; 1688 engineNotice.classList.remove("warning"); 1689 } else if (ocrModeSelect.value === "qari-ocr") { 1690 engineNotice.textContent = "QARI Arabic books selected. Use this on a short sample or strong worker; it is trained for Arabic books, Islamic texts, manuscripts, and layout-aware Arabic transcription."; 1691 engineNotice.classList.remove("warning"); 1692 } else if (ocrModeSelect.value === "tawkeed-ocr") { 1693 engineNotice.textContent = "Tawkeed Arabic OCR selected. Use this on a short sample or worker; it is Arabic-first and trained for documents, handwriting, and scene text."; 1694 engineNotice.classList.remove("warning"); 1695 } else if (ocrModeSelect.value === "katib-ocr") { 1696 engineNotice.textContent = "KATIB Arabic OCR selected. Use this on a short sample; it is a smaller Arabic-trained model for printed and handwritten Arabic."; 1697 engineNotice.classList.remove("warning"); 1698 } else if (ocrModeSelect.value === "arabic-qwen-ocr") { 1699 engineNotice.textContent = "Arabic-Qwen OCR selected. Use this on a short sample or worker; it is a 0.9B Arabic-trained OCR model."; 1700 engineNotice.classList.remove("warning"); 1701 } else if (ocrModeSelect.value === "arabic-glm-ocr") { 1702 engineNotice.textContent = "Arabic-GLM OCR selected. Use this on a short sample or strong worker; it is a recent Arabic-trained OCR model for books and scanned documents."; 1703 engineNotice.classList.remove("warning"); 1704 } else if (ocrModeSelect.value === "baseer-ocr") { 1705 engineNotice.textContent = "Baseer Arabic OCR selected. Use this on a short sample or worker for complex Arabic document layouts."; 1706 engineNotice.classList.remove("warning"); 1707 } else if (ocrModeSelect.value === "best") { 1708 engineNotice.textContent = "Best scan test selected. Use this on a short sample, then run the winning engine for the full book."; 1709 engineNotice.classList.remove("warning"); 1710 } else if (ocrModeSelect.value === "paddleocr") { 1711 engineNotice.textContent = "Rank 3 PaddleOCR Arabic selected. It works, but the 5-page benchmark produced more fragmented text than Tesseract."; 1712 engineNotice.classList.remove("warning"); 1713 } else if (ocrModeSelect.value === "tesseract") { 1714 engineNotice.textContent = "Rank 1 Tesseract Arabic selected. This produced the best readable text in the 5-page OCR benchmark."; 1715 engineNotice.classList.remove("warning"); 1716 } else if (ocrModeSelect.value === "tesseract-fast") { 1717 engineNotice.textContent = "Rank 2 Tesseract Arabic selected. This was the faster readable runner-up in the 5-page OCR benchmark."; 1718 engineNotice.classList.remove("warning"); 1719 } else if (ocrModeSelect.value === "paddleocr-vl") { 1720 engineNotice.textContent = "PaddleOCR-VL selected. Use this only on a
1720short sample or strong worker; it is much heavier than normal Arabic OCR."; 1721 engineNotice.classList.remove("warning"); 1722 } else if (ocrModeSelect.value === "surya") { 1723 engineNotice.textContent = "Surya heavy OCR selected. Use this only on a worker or powerful computer for difficult scanned PDFs."; 1724 engineNotice.classList.remove("warning"); 1725 } 1726} 1727 1728function renderVoiceOptions() { 1729 const voices = cloudMode && !remoteWorkerMode ? voiceCatalog.cloud : voiceCatalog.local; 1730 const fallback = cloudMode && !remoteWorkerMode 1731 ? [{ id: "mms-ara", label: "Arabic Standard" }] 1732 : [{ id: "espeak-ar", label: "Local Arabic" }]; 1733 const options = voices.length ? voices : fallback; 1734 const current = voiceSelect.value || voiceCatalog.default; 1735 voiceSelect.innerHTML = ""; 1736 for (const voice of options) { 1737 const option = document.createElement("option"); 1738 option.value = voice.id; 1739 option.textContent = voice.label; 1740 voiceSelect.append(option); 1741 } 1742 if (options.some((voice) => voice.id === current)) { 1743 voiceSelect.value = current; 1744 } else if (options.some((voice) => voice.id === voiceCatalog.default)) { 1745 voiceSelect.value = voiceCatalog.default; 1746 } 1747} 1748 1749function resetJob(name, message = "Uploading PDF.") { 1750 jobTitle.textContent = name; 1751 setJobState("queued", cloudMode && !remoteWorkerMode ? "Reading" : "Uploading"); 1752 jobMessage.textContent = message; 1753 updateStage({ 1754 id: "upload", 1755 label: cloudMode && !remoteWorkerMode ? "Text scan" : "Upload", 1756 phase: cloudMode && !remoteWorkerMode ? "Checking text layer" : "Uploading PDF", 1757 detail: message, 1758 progress: 2, 1759 steps: defaultStageSteps.map((step, index) => ({ 1760 ...step, 1761 state: index === 0 ? "active" : "pending", 1762 })), 1763 }); 1764 showQualityHint(null); 1765 pagesValue.textContent = "0"; 1766 charactersValue.textContent = "0"; 1767 engineValue.textContent = "-"; 1768 extractionValue.textContent = "-"; 1769 qualityValue.textContent = "-"; 1770 audioFormatValue.textContent = "-"; 1771 audioPanel.classList.add("hidden"); 1772 audioPlayer.removeAttribute("src"); 1773 downloadLink.removeAttribute("href"); 1774 downloadLink.removeAttribute("download"); 1775 resetBrowserSpeechFallback(); 1776 resetTranscriptPanel(); 1777 updateProgress(2); 1778} 1779 1780function updateProgress(value) { 1781 const safeValue = Math.max(0, Math.min(100, Math.round(Number(value) || 0))); 1782 progressBar.style.width = `${safeValue}%`; 1783 progressMeter?.setAttribute("aria-valuenow", String(safeValue)); 1784 progressPercent.textContent = `${safeValue}%`; 1785} 1786 1787function stepStates(activeId) { 1788 const activeIndex = defaultStageSteps.findIndex((step) => step.id === activeId); 1789 return defaultStageSteps.map((step, index) => ({ 1790 ...step, 1791 state: activeIndex < 0 ? "pending" : index < activeIndex ? "done" : index === activeIndex ? "active" : "pending", 1792 })); 1793} 1794 1795function stageItem(label, current, total) { 1796 const safeTotal = Math.max(1, Number(total) || 1); 1797 const safeCurrent = Math.max(0, Math.min(safeTotal, Number(current) || 0)); 1798 return { 1799 label, 1800 current: safeCurrent, 1801 total: safeTotal, 1802 percent: Math.round((safeCurrent / safeTotal) * 100), 1803 }; 1804} 1805 1806function formatProgressCount(value) { 1807 const number = Number(value) || 0; 1808 if (Number.isInteger(number)) return String(number); 1809 return number.toFixed(1); 1810} 1811 1812function updateStage(stage) { 1813 const progress = stage?.progress ?? 0; 1814 updateProgress(progress); 1815 const label = stage?.label || "Working"; 1816 const phase = stage?.phase || "Current step"; 1817 const stepText = stage?.step && stage?.totalSteps ? `Step ${stage.step} of ${stage.totalSteps}` : "Current step"; 1818 const overallLabel = stage?.overallLabel || "Overall progress"; 1819 const labelWithStep = stage?.label 1820 ? `${label}${stage.step && stage.totalSteps ? ` (${stage.step} of ${stage.totalSteps})` : ""}` 1821 : "Working"; 1822 stageLabel.textContent = labelWithStep; 1823 stagePhaseLabel.textContent = `${stepText} - ${overallLabel} ${Math.round(Number(progress) || 0)}%`; 1824 stageDetailTitle.textContent = phase === "Current step" ? labelWithStep : phase; 1825 stageDetailText.textContent = stage?.detail || jobMessage.textContent || "Working on the PDF."; 1826 const itemProgress = stage?.itemProgress; 1827 if (itemProgress && itemProgress.total) { 1828 const percent = Math.max(0, Math.min(100, Math.round(Number(itemProgress.percent) || 0))); 1829 stageItemProgress.classList.remove("hidden"); 1830 stageItemLabel.textContent = itemProgress.label || "Progress"; 1831 stageItemValue.textContent = 1832 `${formatProgressCount(itemProgress.current)} of ${formatProgressCount(itemProgress.total)} (${percent}%)`; 1833 stageItemBar.style.width = `${percent}%`; 1834 } else { 1835 stageItemProgress.classList.add("hidden"); 1836 stageItemLabel.textContent = "Progress"; 1837 stageItemValue.textContent = "0 of 0"; 1838 stageItemBar.style.width = "0%"; 1839 } 1840 const isServerJob = !cloudMode || remoteWorkerMode; 1841 const backgroundText = isServerJob 1842 ? "The server keeps working even if you are not watching this screen." 1843 : "Keep this tab open while browser OCR is reading scanned pages."; 1844 stageMetaText.textContent = `Updated just now. ${backgroundText}`; 1845 const steps = stage?.steps?.length ? stage.steps : defaultStageSteps; 1846 stageList.innerHTML = ""; 1847 for (const step of steps) { 1848 const item = document.createElement("li"); 1849 item.className = step.state || "pending"; 1850 const dot = document.createElement("span"); 1851 dot.className = "stage-dot"; 1852 dot.setAttribute("aria-hidden", "true"); 1853 const text = document.createElement("span"); 1854 text.textContent = step.label; 1855 item.append(dot, text); 1856 stageList.append(item); 1857 } 1858} 1859 1860function setJobState(status, label = statusLabels[status] || status) { 1861 jobState.textContent = label; 1862 jobState.classList.toggle("complete", status === "complete"); 1863 jobState.classList.toggle("failed", status === "failed"); 1864} 1865 1866function startPolling(jobId) { 1867 activeJobId = jobId; 1868 clearInterval(pollTimer); 1869 pollTimer = setInterval(() => pollJob(jobId), 750); 1870 pollJob(jobId); 1871} 1872 1873async function pollJob(jobId) { 1874 try { 1875 const job = await apiFetch(`/api/jobs/${jobId}`).then(readJson); 1876 jobTitle.textContent = job.filename || "Arabic PDF"; 1877 setJobState(job.status); 1878 jobMessage.textContent = job.error || job.message; 1879 showQualityHint({ 1880 quality: job.textQuality, 1881 reasons: job.qualityReasons || [], 1882 readyForTts: job.textQuality && job.textQuality !== "poor", 1883 }); 1884 pagesValue.textContent = formatPages(job); 1885 charactersValue.textContent = (job.characters || 0).toLocaleStr
1885ing(); 1886 engineValue.textContent = job.engine || "-"; 1887 extractionValue.textContent = job.extraction || "-"; 1888 qualityValue.textContent = job.textQuality 1889 ? `${qualityLabels[job.textQuality] || job.textQuality} ${job.qualityScore ? Math.round(job.qualityScore) : ""}`.trim() 1890 : "-"; 1891 audioFormatValue.textContent = job.audioFormat 1892 ? `${job.audioFormat.toUpperCase()} ${formatBytes(job.audioBytes)}` 1893 : "-"; 1894 updateStage(job.stage || { progress: job.progress || 0, label: statusLabels[job.status] || job.status }); 1895 updateTranscriptPanel(job); 1896 if (job.status === "complete") { 1897 clearInterval(pollTimer); 1898 pollTimer = null; 1899 activeJobId = null; 1900 createButton.disabled = false; 1901 compareButton.disabled = false; 1902 audioPlayer.src = mediaUrl(job.audioUrl); 1903 downloadLink.href = mediaUrl(job.downloadUrl); 1904 audioPanel.classList.remove("hidden"); 1905 loadHistory(); 1906 loadLibrary(); 1907 } 1908 if (job.status === "failed") { 1909 clearInterval(pollTimer); 1910 pollTimer = null; 1911 activeJobId = null; 1912 createButton.disabled = false; 1913 compareButton.disabled = false; 1914 loadHistory(); 1915 } 1916 } catch (error) { 1917 clearInterval(pollTimer); 1918 pollTimer = null; 1919 activeJobId = null; 1920 createButton.disabled = false; 1921 compareButton.disabled = false; 1922 setError(uploadError, error.message); 1923 } 1924} 1925 1926function formatBytes(value) { 1927 if (!value) return ""; 1928 const units = ["B", "KB", "MB", "GB"]; 1929 let size = Number(value); 1930 let unit = 0; 1931 while (size >= 1024 && unit < units.length - 1) { 1932 size /= 1024; 1933 unit += 1; 1934 } 1935 return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`; 1936} 1937 1938function formatPages(job) { 1939 const pages = job.pages || 0; 1940 const total = job.totalPages || pages; 1941 if (total && total > pages) return `${pages} / ${total}`; 1942 return `${pages}`; 1943} 1944 1945function showQualityHint(quality) { 1946 if (!quality || !quality.quality || quality.quality === "good") { 1947 qualityHint.textContent = ""; 1948 qualityHint.classList.add("hidden"); 1949 qualityHint.classList.remove("poor"); 1950 return; 1951 } 1952 const reasons = quality.reasons?.length ? ` ${quality.reasons.join("; ")}.` : ""; 1953 const action = quality.quality === "poor" 1954 ? "Try 1. Tesseract Arabic - Best readable, Best scan test, or another OCR mode before creating audio." 1955 : "Listen to a short sample before running the full book. If it sounds wrong, try 1. Tesseract Arabic - Best readable, Best scan test, or another OCR mode."; 1956 qualityHint.textContent = `Text needs checking.${reasons} ${action}`; 1957 qualityHint.classList.remove("hidden"); 1958 qualityHint.classList.toggle("poor", quality.quality === "poor"); 1959} 1960 1961transcriptRefreshButton.addEventListener("click", loadFullTranscript); 1962renderVoiceOptions(); 1963checkSession();
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.