PageSourceSearch

https://syncre-arabic-audio-reader-worker.hf.space/static/app.js?v=20260608-persistent-library

js syncre-arabic-audio-reader-worker.hf.space collected 2026-10-03 10:21:23 UTC 75,280 bytes, 1,963 lines download raw bytes

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.