diff --git a/static/app.js b/static/app.js index 5bf2726..cadfe77 100644 --- a/static/app.js +++ b/static/app.js @@ -21,8 +21,12 @@ const audioCache = new Map(); let allModels = {}; let currentUser = null; -// PDF blob URL for native browser viewer -let pdfBlobUrl = null; +// PDF.js state +const PDF_SCALE = 1.5; +let pdfJsDoc = null; +let pdfAllWords = []; // [{page, x, y, w, h, str}] +let pdfWordMap = []; // sentence_idx → [word_idx, ...] +let pageOverlays = []; // [{overlay, wrap}] per page // ── DOM ──────────────────────────────────────────────────────────── const $ = id => document.getElementById(id); @@ -203,52 +207,139 @@ function setView(mode) { } window.setView = setView; -// ── PDF NATIVE RENDERING ─────────────────────────────────────────── -function renderPDF(b64) { - // Revoke previous blob to free memory - if (pdfBlobUrl) { URL.revokeObjectURL(pdfBlobUrl); pdfBlobUrl = null; } +// ── PDF.js RENDERING + HIGHLIGHTING ─────────────────────────────── +async function renderPDF(source) { + // source: File object OR base64 string (from Nextcloud) + pdfjsLib.GlobalWorkerOptions.workerSrc = + 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; - const binary = atob(b64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - const blob = new Blob([bytes], { type: 'application/pdf' }); - pdfBlobUrl = URL.createObjectURL(blob); - - const pdfPages = $('pdf-pages'); - pdfPages.innerHTML = ''; - - const iframe = document.createElement('iframe'); - iframe.id = 'pdf-iframe'; - iframe.src = pdfBlobUrl; - iframe.style.cssText = 'width:100%;height:100%;border:none;display:block;'; - pdfPages.appendChild(iframe); -} - -function buildSentencePageMap(pageTexts, sentences) { - // For each real sentence, find which page it appears on - const map = new Array(sentences.length).fill(0); - let pageIdx = 0; - const lowerPages = pageTexts.map(p => p.toLowerCase().replace(/\s+/g, ' ')); - - for (let i = 0; i < sentences.length; i++) { - if (sentences[i].isPara) { map[i] = pageIdx; continue; } - const snippet = sentences[i].text.slice(0, 40).toLowerCase().replace(/\s+/g, ' '); - // Search from current page forward - for (let p = pageIdx; p < lowerPages.length; p++) { - if (lowerPages[p].includes(snippet)) { pageIdx = p; break; } - } - map[i] = pageIdx; + let data; + if (typeof source === 'string') { + const bin = atob(source); + data = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) data[i] = bin.charCodeAt(i); + } else { + data = new Uint8Array(await source.arrayBuffer()); + } + + pdfJsDoc = await pdfjsLib.getDocument({ data }).promise; + pageOverlays = []; + pdfAllWords = []; + + const container = $('pdf-pages'); + container.innerHTML = ''; + + for (let pageNum = 1; pageNum <= pdfJsDoc.numPages; pageNum++) { + const page = await pdfJsDoc.getPage(pageNum); + const vp = page.getViewport({ scale: PDF_SCALE }); + + // Page wrapper (relative so overlay sits on top) + const wrap = document.createElement('div'); + wrap.className = 'pdf-page-wrap'; + wrap.style.cssText = `position:relative;width:${vp.width}px;max-width:100%;flex-shrink:0;`; + + // Render canvas + const canvas = document.createElement('canvas'); + canvas.width = vp.width; + canvas.height = vp.height; + canvas.style.cssText = 'display:block;width:100%;'; + await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise; + + // Transparent overlay for highlights + const overlay = document.createElement('canvas'); + overlay.width = vp.width; + overlay.height = vp.height; + overlay.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;'; + + wrap.appendChild(canvas); + wrap.appendChild(overlay); + container.appendChild(wrap); + pageOverlays.push({ overlay, wrap }); + + // Extract word positions (PDF user-space → canvas pixels) + const tc = await page.getTextContent(); + for (const item of tc.items) { + const s = item.str.trim(); + if (!s) continue; + // convertToViewportPoint flips y: PDF has y-up, canvas has y-down + const [cx, cy] = vp.convertToViewportPoint(item.transform[4], item.transform[5]); + const wPx = Math.abs(item.width * PDF_SCALE); + const hPx = Math.abs(item.height * PDF_SCALE); + pdfAllWords.push({ + page: pageNum - 1, + x: cx, y: cy - hPx, // cy is baseline; move up by height to get top + w: wPx, h: hPx, + str: s, + }); + } } - return map; } -function scrollPDFToPage(pageIdx) { - if (!pdfBlobUrl) return; - const iframe = $('pdf-iframe'); - if (!iframe) return; - // #page= fragment navigates inside Chrome/Edge native PDF viewer - const target = pdfBlobUrl + '#page=' + (pageIdx + 1); - if (iframe.src !== target) iframe.src = target; +// Map each sentence to the word indices it covers in pdfAllWords +function buildPDFWordMap(sentences) { + const strs = pdfAllWords.map(w => w.str); + const allText = strs.join(' '); + + // Pre-compute character start of every word + const charStarts = []; + let pos = 0; + for (const s of strs) { charStarts.push(pos); pos += s.length + 1; } + + pdfWordMap = []; + let searchFrom = 0; + + for (const sent of sentences) { + if (sent.isPara) { pdfWordMap.push([]); continue; } + + const needle = sent.text.replace(/\s+/g, ' ').trim(); + let found = allText.indexOf(needle, searchFrom); + // Fallback: try first 50 chars (handles slight extraction differences) + if (found === -1) found = allText.indexOf(needle.slice(0, 50), searchFrom); + if (found === -1) { pdfWordMap.push([]); continue; } + + const end = found + needle.length; + const idxs = []; + for (let i = 0; i < charStarts.length; i++) { + if (charStarts[i] + strs[i].length > found && charStarts[i] < end) idxs.push(i); + } + pdfWordMap.push(idxs); + searchFrom = Math.max(0, found - 5); + } +} + +function clearPDFHighlights() { + for (const { overlay } of pageOverlays) { + overlay.getContext('2d').clearRect(0, 0, overlay.width, overlay.height); + } +} + +function highlightPDFSentence(sentIdx) { + clearPDFHighlights(); + const idxs = pdfWordMap[sentIdx] || []; + if (!idxs.length) return; + + // Draw highlight rects grouped by page + const byPage = {}; + for (const wi of idxs) { + const w = pdfAllWords[wi]; + (byPage[w.page] ??= []).push(w); + } + for (const [pg, words] of Object.entries(byPage)) { + const entry = pageOverlays[Number(pg)]; + if (!entry) continue; + const ctx = entry.overlay.getContext('2d'); + ctx.fillStyle = 'rgba(124,58,237,0.38)'; + for (const w of words) { + ctx.fillRect(w.x - 1, w.y - 1, w.w + 2, w.h + 2); + } + } + + // Scroll to page of first word + const firstWord = pdfAllWords[idxs[0]]; + if (firstWord !== undefined) { + const entry = pageOverlays[firstWord.page]; + if (entry) entry.wrap.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } } // ── LOAD DOCUMENT ────────────────────────────────────────────────── @@ -274,18 +365,16 @@ async function loadDoc(text, title = 'Document', docId = null, startIdx = 0, pdf renderText(state.sentences); textViewer.style.fontSize = `${state.fontSize}px`; - // Set up PDF view + // Set up PDF view (for saved Nextcloud docs that return pdf_b64) const viewToggle = $('view-toggle'); if (pdfB64) { viewToggle.style.display = ''; - state.sentencePage = buildSentencePageMap(pageTexts, state.sentences); - renderPDF(pdfB64); - setView('pdf'); // default to real PDF view + setView('pdf'); + renderPDF(pdfB64).then(() => buildPDFWordMap(state.sentences)).catch(console.error); } else { viewToggle.style.display = 'none'; setView('text'); - state.sentencePage = []; - if (pdfBlobUrl) { URL.revokeObjectURL(pdfBlobUrl); pdfBlobUrl = null; } + pdfJsDoc = null; pdfAllWords = []; pdfWordMap = []; pageOverlays = []; $('pdf-pages').innerHTML = ''; } @@ -380,9 +469,9 @@ function highlight(idx) { scheduleSaveProgress(); - // Sync PDF view to current page - if (state.currentView === 'pdf' && state.sentencePage.length) { - scrollPDFToPage(state.sentencePage[idx] ?? 0); + // Highlight directly on PDF canvas + if (state.currentView === 'pdf' && pdfWordMap.length) { + highlightPDFSentence(idx); } } @@ -589,59 +678,57 @@ async function handlePDF(file) { const status = $('pdf-status'); const title = file.name.replace(/\.pdf$/i, ''); - // ── Step 1: show PDF instantly from local file, no server needed ── - if (pdfBlobUrl) URL.revokeObjectURL(pdfBlobUrl); - pdfBlobUrl = URL.createObjectURL(file); - - // Show header + PDF view right away + // Show header immediately in PDF mode $('document-header').style.display = ''; $('document-title').textContent = title; - $('document-meta').textContent = 'Extracting text for playback…'; + $('document-meta').textContent = 'Loading…'; $('view-toggle').style.display = ''; $('pdf-pages').innerHTML = ''; - const iframe = document.createElement('iframe'); - iframe.id = 'pdf-iframe'; - iframe.src = pdfBlobUrl; - iframe.style.cssText = 'width:100%;height:100%;border:none;display:block;'; - $('pdf-pages').appendChild(iframe); - setView('pdf'); // ← show the real PDF immediately + setView('pdf'); + stopPlayback(); + audioCache.clear(); + state.sentences = []; state.docId = null; state.docTitle = title; + pdfJsDoc = null; pdfAllWords = []; pdfWordMap = []; pageOverlays = []; + nowPlaying.textContent = 'Loading PDF…'; + status.textContent = 'Loading…'; - nowPlaying.textContent = 'Extracting text for playback…'; - status.textContent = 'Extracting…'; + // Run PDF rendering (shows pages as they complete) and text extraction in parallel + const form = new FormData(); + form.append('file', file); + + const renderPromise = renderPDF(file); // reads File directly, no server + const extractPromise = fetch('/api/extract-pdf', { method: 'POST', body: form }) + .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.detail))); - // ── Step 2: extract text in background (needed only for TTS) ── try { - const form = new FormData(); - form.append('file', file); - const resp = await fetch('/api/extract-pdf', { method: 'POST', body: form }); - if (!resp.ok) throw new Error((await resp.json()).detail || 'Failed'); - const data = await resp.json(); + // Text extraction usually finishes before all pages render + const data = await extractPromise; - // Update state with extracted sentences (keep PDF view active) state.sentences = splitSentences(data.text); state.docTitle = data.title || title; state.currentIdx = 0; - state.docId = null; - state.pageTexts = data.page_texts || []; - state.sentencePage = buildSentencePageMap(state.pageTexts, state.sentences); - audioCache.clear(); const real = state.sentences.filter(s => !s.isPara).length; - $('document-meta').textContent = `${real} sentences · ready`; - $('document-title').textContent = data.title || title; + $('document-meta').textContent = `${real} sentences`; + $('document-title').textContent = state.docTitle; totSent.textContent = real; curSent.textContent = 0; progressFill.style.width = '0%'; - nowPlaying.textContent = 'Ready — press play'; - // Also render text view in background for when user switches + // Render text view in background for switching renderText(state.sentences); textViewer.style.fontSize = `${state.fontSize}px`; + // Wait for PDF rendering to finish, then build word→sentence map + await renderPromise; + buildPDFWordMap(state.sentences); + + nowPlaying.textContent = 'Ready — press play'; status.textContent = '✓ Ready'; } catch (e) { - status.textContent = '✗ ' + e.message; - nowPlaying.textContent = 'Text extraction failed'; + await renderPromise.catch(() => {}); + status.textContent = '✗ ' + (e.message || e); + nowPlaying.textContent = 'Error — check PDF tab is visible'; } } @@ -724,7 +811,8 @@ async function ncOpenFile(path, name) { }); if (!resp.ok) throw new Error((await resp.json()).detail || 'Failed'); const data = await resp.json(); - await loadDoc(data.text, data.title || name.replace(/\.pdf$/i, ''), null, 0, data.pdf_b64, data.page_texts || []); + // loadDoc handles pdf_b64 via PDF.js + await loadDoc(data.text, data.title || name.replace(/\.pdf$/i, ''), null, 0, data.pdf_b64 || null, data.page_texts || []); status.textContent = '✓ Loaded'; } catch (e) { status.textContent = '✗ ' + e.message; diff --git a/static/index.html b/static/index.html index ea6f8f2..aa66fd3 100644 --- a/static/index.html +++ b/static/index.html @@ -219,6 +219,7 @@

Loading…

+ diff --git a/static/style.css b/static/style.css index dae5d20..0c4095f 100644 --- a/static/style.css +++ b/static/style.css @@ -204,8 +204,8 @@ textarea:focus, .text-field:focus { border-color: var(--accent); } min-height: 0; } .content-area.pdf-mode { - padding: 20px 20px 0; - overflow: hidden; /* iframe fills remaining height */ + padding: 16px 16px 0; + overflow: hidden; /* pdf-viewer child handles its own scroll */ } .document-header { margin-bottom: 28px; } .doc-title-row { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 6px; } @@ -286,25 +286,30 @@ textarea:focus, .text-field:focus { border-color: var(--accent); } /* ── PDF VIEWER ──────────────────────────── */ .pdf-viewer { flex: 1; - min-height: 0; /* essential: lets flex child shrink below content size */ + min-height: 0; + overflow-y: auto; + background: #111; + padding: 24px; display: flex; flex-direction: column; - overflow: hidden; + align-items: center; + gap: 16px; } #pdf-pages { - flex: 1; - min-height: 0; display: flex; flex-direction: column; - overflow: hidden; -} -#pdf-iframe { - flex: 1; - min-height: 0; + align-items: center; + gap: 16px; width: 100%; - border: none; - display: block; } +.pdf-page-wrap { + box-shadow: 0 4px 20px rgba(0,0,0,.6); + background: #fff; + border-radius: 2px; + position: relative; + max-width: 900px; +} +.pdf-page-wrap canvas { display: block; width: 100%; } /* ── SETTINGS SIDEBAR ────────────────────── */ .setting-group { padding: 16px; border-bottom: 1px solid var(--border); }