Add PDF.js viewer, column-aware PDF extraction, and view toggle

- PDF pages rendered via PDF.js (see actual document, not just extracted text)
- Text/PDF view toggle appears for PDF documents (top-right of header)
- PDF view scrolls to current page as sentences are read
- Column-aware extraction: detects two-column layouts, reads left column
  top-to-bottom then right column (fixes academic papers, double-page PDFs)
- De-hyphenation: removes mid-word line breaks from extracted text
- Both PDF upload and Nextcloud download now return pdf_b64 + page_texts
- sentence→page mapping so PDF view auto-scrolls during playback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ifedan-ed 2026-04-11 04:16:56 +02:00
parent ee3ffcfe90
commit 00af54c95a
4 changed files with 253 additions and 24 deletions

81
main.py
View file

@ -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))

View file

@ -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;

View file

@ -111,18 +111,27 @@
</aside>
<!-- Main Content -->
<main class="content-area">
<main class="content-area" id="content-area">
<div id="document-header" class="document-header" style="display:none">
<div class="doc-title-row">
<h1 class="document-title" id="document-title"></h1>
<button class="btn-save" id="btn-save-doc" title="Save document">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Save
</button>
<div class="doc-title-actions">
<!-- View toggle (only shown for PDFs) -->
<div class="view-toggle" id="view-toggle" style="display:none">
<button class="view-btn active" id="btn-view-text" onclick="setView('text')">Text</button>
<button class="view-btn" id="btn-view-pdf" onclick="setView('pdf')">PDF</button>
</div>
<button class="btn-save" id="btn-save-doc" title="Save document">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Save
</button>
</div>
</div>
<p class="document-meta" id="document-meta"></p>
<div class="doc-hint">Click any sentence to jump there · Space to play/pause · ← → to skip</div>
<div class="doc-hint" id="doc-hint">Click any sentence to jump there · Space to play/pause · ← → to skip</div>
</div>
<!-- Text view -->
<div class="text-viewer" id="text-viewer">
<div class="empty-state">
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
@ -130,6 +139,11 @@
<span>Paste text, upload a PDF, enter a URL, or pick from Nextcloud</span>
</div>
</div>
<!-- PDF view (PDF.js rendered pages) -->
<div class="pdf-viewer" id="pdf-viewer" style="display:none">
<div id="pdf-pages"></div>
</div>
</main>
<!-- Right Sidebar: Settings -->
@ -205,6 +219,7 @@
<p id="loading-text">Loading…</p>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="/static/app.js"></script>
</body>
</html>

View file

@ -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 {