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:
parent
ee3ffcfe90
commit
00af54c95a
4 changed files with 253 additions and 24 deletions
81
main.py
81
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))
|
||||
|
||||
|
|
|
|||
133
static/app.js
133
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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue