diff --git a/main.py b/main.py index 5409020..4d022d1 100644 --- a/main.py +++ b/main.py @@ -687,11 +687,11 @@ async def download_nextcloud_file(req: NextcloudDownloadRequest, if r.status_code != 200: raise HTTPException(status_code=r.status_code, detail="File download failed") - doc = fitz.open(stream=r.content, filetype="pdf") - pages = [page.get_text() for page in doc] - text = re.sub(r'\n{3,}', '\n\n', "\n\n".join(pages)).strip() - title = req.path.split("/")[-1] - return {"text": text, "title": title} + import base64 + full_text, page_texts = _extract_pdf(r.content) + pdf_b64 = base64.b64encode(r.content).decode() + title = req.path.split("/")[-1].removesuffix(".pdf").removesuffix(".PDF") + return {"text": full_text, "title": title, "page_texts": page_texts, "pdf_b64": pdf_b64} except HTTPException: raise except Exception as e: @@ -751,19 +751,72 @@ async def extract_from_url(req: URLRequest, _: User = Depends(get_current_user)) # ── PDF EXTRACTION ──────────────────────────────────────────────────── +def _extract_pdf(content: bytes) -> tuple[str, list[str]]: + """ + Extract text from PDF bytes using block-aware reading order. + Returns (full_text, page_texts[]). + + For multi-column layouts, detects column boundaries per page + and reads left column top-to-bottom, then right column top-to-bottom, + which matches human reading order. + """ + import fitz + import base64 + + doc = fitz.open(stream=content, filetype="pdf") + page_texts = [] + + for page in doc: + pw = page.rect.width + # Get text blocks: (x0,y0,x1,y1, text, block_no, block_type) + blocks = [b for b in page.get_text("blocks", sort=True) if b[6] == 0 and b[4].strip()] + + if not blocks: + page_texts.append("") + continue + + # Detect two-column: if significant blocks appear on both sides of page midpoint + mid = pw * 0.52 + left_blocks = [b for b in blocks if b[2] <= mid] # right edge ≤ midpoint + right_blocks = [b for b in blocks if b[0] >= mid * 0.48] # left edge ≥ ~midpoint + + is_two_col = ( + len(left_blocks) >= 3 and len(right_blocks) >= 3 + and len(left_blocks) + len(right_blocks) >= len(blocks) * 0.75 + ) + + if is_two_col: + # Segregate by horizontal midpoint + col_left = [b for b in blocks if (b[0] + b[2]) / 2 < mid] + col_right = [b for b in blocks if (b[0] + b[2]) / 2 >= mid] + ordered = sorted(col_left, key=lambda b: b[1]) + sorted(col_right, key=lambda b: b[1]) + else: + ordered = sorted(blocks, key=lambda b: (round(b[1] / 20), b[0])) # row-then-col + + txt = "\n\n".join(b[4].strip() for b in ordered) + txt = re.sub(r'[ \t]+', ' ', txt) # collapse whitespace within lines + txt = re.sub(r'\n{3,}', '\n\n', txt) # max double newlines + # de-hyphenate: "compli-\ncated" → "complicated" + txt = re.sub(r'-\n(\w)', r'\1', txt) + page_texts.append(txt.strip()) + + full_text = re.sub(r'\n{3,}', '\n\n', "\n\n".join(page_texts)).strip() + return full_text, page_texts + + @app.post("/api/extract-pdf") async def extract_from_pdf(file: UploadFile = File(...), _: User = Depends(get_current_user)): - """ - Reads the binary PDF file, extracts all text content from each page, - and returns it as plain text that can be fed to TTS. - """ try: - import fitz # PyMuPDF + import base64 content = await file.read() - doc = fitz.open(stream=content, filetype="pdf") - pages = [page.get_text() for page in doc] - text = re.sub(r'\n{3,}', '\n\n', "\n\n".join(pages)).strip() - return {"text": text, "title": file.filename} + full_text, page_texts = _extract_pdf(content) + pdf_b64 = base64.b64encode(content).decode() + return { + "text": full_text, + "title": file.filename.removesuffix(".pdf").removesuffix(".PDF"), + "page_texts": page_texts, + "pdf_b64": pdf_b64, + } except Exception as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/static/app.js b/static/app.js index 547d565..cf885a2 100644 --- a/static/app.js +++ b/static/app.js @@ -7,8 +7,13 @@ const state = { model: 'openai-tts-1', voice: 'nova', fontSize: 18, - docId: null, // currently loaded saved-doc id + docId: null, docTitle: '', + // PDF state + pdfB64: null, // base64 PDF data + pageTexts: [], // text per page for page-sentence mapping + sentencePage: [], // maps sentence index → page index (0-based) + currentView: 'text', // 'text' | 'pdf' }; let currentAudio = null; @@ -16,6 +21,10 @@ const audioCache = new Map(); let allModels = {}; let currentUser = null; +// PDF.js state +let pdfDoc = null; +const pageCanvases = []; // canvas elements per page + // ── DOM ──────────────────────────────────────────────────────────── const $ = id => document.getElementById(id); const textViewer = $('text-viewer'); @@ -172,12 +181,106 @@ function renderText(sentences) { }); } +// ── VIEW SWITCHING ───────────────────────────────────────────────── +function setView(mode) { + state.currentView = mode; + const textViewer = $('text-viewer'); + const pdfViewer = $('pdf-viewer'); + const contentArea = $('content-area'); + const btnText = $('btn-view-text'); + const btnPdf = $('btn-view-pdf'); + + if (mode === 'pdf') { + textViewer.style.display = 'none'; + pdfViewer.style.display = ''; + contentArea.classList.add('pdf-mode'); + btnPdf.classList.add('active'); + btnText.classList.remove('active'); + $('doc-hint').textContent = 'Viewing original PDF · switch to Text to click sentences'; + } else { + textViewer.style.display = ''; + pdfViewer.style.display = 'none'; + contentArea.classList.remove('pdf-mode'); + btnText.classList.add('active'); + btnPdf.classList.remove('active'); + $('doc-hint').textContent = 'Click any sentence to jump there · Space to play/pause · ← → to skip'; + } +} +window.setView = setView; + +// ── PDF.js RENDERING ─────────────────────────────────────────────── +async function renderPDF(b64) { + if (typeof pdfjsLib === 'undefined') return; + pdfjsLib.GlobalWorkerOptions.workerSrc = + 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; + + const pdfPages = $('pdf-pages'); + pdfPages.innerHTML = ''; + pageCanvases.length = 0; + + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + + pdfDoc = await pdfjsLib.getDocument({ data: bytes }).promise; + + for (let i = 1; i <= pdfDoc.numPages; i++) { + const page = await pdfDoc.getPage(i); + const vp = page.getViewport({ scale: 1.5 }); + + const wrap = document.createElement('div'); + wrap.className = 'pdf-page-wrap'; + wrap.dataset.page = i - 1; + + const canvas = document.createElement('canvas'); + canvas.width = vp.width; + canvas.height = vp.height; + canvas.style.width = Math.min(vp.width, 800) + 'px'; + canvas.style.height = (Math.min(vp.width, 800) / vp.width * vp.height) + 'px'; + + await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise; + wrap.appendChild(canvas); + + const lbl = document.createElement('div'); + lbl.className = 'pdf-page-num'; + lbl.textContent = `Page ${i}`; + pdfPages.appendChild(wrap); + pdfPages.appendChild(lbl); + pageCanvases.push(wrap); + } +} + +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; + } + return map; +} + +function scrollPDFToPage(pageIdx) { + if (pageIdx < 0 || pageIdx >= pageCanvases.length) return; + pageCanvases[pageIdx].scrollIntoView({ behavior: 'smooth', block: 'start' }); +} + // ── LOAD DOCUMENT ────────────────────────────────────────────────── -async function loadDoc(text, title = 'Document', docId = null, startIdx = 0) { +async function loadDoc(text, title = 'Document', docId = null, startIdx = 0, pdfB64 = null, pageTexts = []) { state.sentences = splitSentences(text); state.currentIdx = startIdx; state.docId = docId; state.docTitle = title; + state.pdfB64 = pdfB64; + state.pageTexts = pageTexts; audioCache.clear(); stopPlayback(); @@ -193,6 +296,23 @@ async function loadDoc(text, title = 'Document', docId = null, startIdx = 0) { renderText(state.sentences); textViewer.style.fontSize = `${state.fontSize}px`; + // Set up PDF view + const viewToggle = $('view-toggle'); + if (pdfB64) { + viewToggle.style.display = ''; + state.sentencePage = buildSentencePageMap(pageTexts, state.sentences); + // Render asynchronously so the text view appears immediately + renderPDF(pdfB64).catch(console.error); + // Default to text view + setView('text'); + } else { + viewToggle.style.display = 'none'; + setView('text'); + state.sentencePage = []; + pdfDoc = null; + $('pdf-pages').innerHTML = ''; + } + // Load saved progress if we have a docId and no explicit startIdx if (docId && startIdx === 0) { try { @@ -283,6 +403,11 @@ function highlight(idx) { progressFill.style.width = realTotal ? `${(realIdx / realTotal) * 100}%` : '0%'; scheduleSaveProgress(); + + // Sync PDF view to current page + if (state.currentView === 'pdf' && state.sentencePage.length) { + scrollPDFToPage(state.sentencePage[idx] ?? 0); + } } // ── NAVIGATION HELPERS ───────────────────────────────────────────── @@ -493,7 +618,7 @@ async function uploadPDF(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(); - loadDoc(data.text, data.title || file.name); + await loadDoc(data.text, data.title || file.name, null, 0, data.pdf_b64, data.page_texts || []); status.textContent = '✓ Loaded'; } catch (e) { status.textContent = '✗ ' + e.message; @@ -581,7 +706,7 @@ async function ncOpenFile(path, name) { }); if (!resp.ok) throw new Error((await resp.json()).detail || 'Failed'); const data = await resp.json(); - loadDoc(data.text, name.replace(/\.pdf$/i, '')); + await loadDoc(data.text, data.title || name.replace(/\.pdf$/i, ''), null, 0, data.pdf_b64, data.page_texts || []); status.textContent = '✓ Loaded'; } catch (e) { status.textContent = '✗ ' + e.message; diff --git a/static/index.html b/static/index.html index 9845222..aa66fd3 100644 --- a/static/index.html +++ b/static/index.html @@ -111,18 +111,27 @@ -
+
+ +
@@ -130,6 +139,11 @@ Paste text, upload a PDF, enter a URL, or pick from Nextcloud
+ + +
@@ -205,6 +219,7 @@

Loading…

+ diff --git a/static/style.css b/static/style.css index 56c6902..ceb0796 100644 --- a/static/style.css +++ b/static/style.css @@ -200,7 +200,9 @@ textarea:focus, .text-field:focus { border-color: var(--accent); } /* ── MAIN CONTENT AREA ───────────────────── */ .content-area { flex: 1; overflow-y: auto; padding: 44px 56px; + display: flex; flex-direction: column; } +.content-area.pdf-mode { padding: 24px 24px 0; overflow: hidden; } .document-header { margin-bottom: 28px; } .doc-title-row { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 6px; } .document-title { font-size: 22px; font-weight: 700; flex: 1; } @@ -263,6 +265,40 @@ textarea:focus, .text-field:focus { border-color: var(--accent); } .saved-item-title { font-size: 13px; font-weight: 500; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .saved-item-meta { font-size: 11px; color: var(--text-m); margin-top: 3px; } +/* ── VIEW TOGGLE ────────────────────────── */ +.doc-title-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } +.view-toggle { + display: flex; background: var(--bg3); border: 1px solid var(--border); + border-radius: 8px; padding: 3px; gap: 2px; +} +.view-btn { + background: none; border: none; border-radius: 6px; color: var(--text-m); + cursor: pointer; font-family: inherit; font-size: 12px; font-weight: 500; + padding: 4px 12px; transition: all .15s; +} +.view-btn.active { background: var(--accent); color: #fff; } +.view-btn:hover:not(.active) { color: var(--text); background: var(--bg4); } + +/* ── PDF VIEWER ──────────────────────────── */ +.pdf-viewer { + flex: 1; overflow-y: auto; padding: 24px; background: #1a1a1a; + display: flex; flex-direction: column; align-items: center; gap: 16px; +} +#pdf-pages { display: flex; flex-direction: column; gap: 16px; align-items: center; width: 100%; } +.pdf-page-wrap { + position: relative; box-shadow: 0 4px 24px rgba(0,0,0,.5); + border-radius: 4px; overflow: hidden; max-width: 100%; +} +.pdf-page-wrap canvas { display: block; max-width: 100%; height: auto; } +.pdf-page-highlight { + position: absolute; border-radius: 2px; + background: rgba(124,58,237,.35); + pointer-events: none; transition: opacity .2s; +} +.pdf-page-num { + text-align: center; font-size: 11px; color: var(--text-d); margin-top: 4px; +} + /* ── SETTINGS SIDEBAR ────────────────────── */ .setting-group { padding: 16px; border-bottom: 1px solid var(--border); } .setting-label {