diff --git a/docs/UserGuide.md b/docs/UserGuide.md index 39c83cb4..57a4d56d 100644 --- a/docs/UserGuide.md +++ b/docs/UserGuide.md @@ -165,15 +165,30 @@ If metadata has been extracted by GPT, this section displays structured informat - Understanding the complete data structure #### Document Previews -View your documents side-by-side in embedded PDF viewers: +View your documents with type-specific viewers: - **Original Document**: The immutable original file as first ingested, before any processing - **Processed Document**: The final file with embedded metadata -**Features**: -- In-browser PDF rendering for immediate viewing -- Side-by-side comparison of original vs processed versions -- Full 600px height previews for detailed review -- **View Extracted Text** buttons below each preview to see the full text content +**PDF Preview (pdf.js)**: +- In-browser PDF rendering via pdf.js — no native browser PDF plugin required +- Page navigation controls (previous / next) with page counter +- High-quality canvas-based rendering that works consistently across browsers +- Side-by-side comparison of original vs processed versions on the detail page + +**Image Preview (JPEG, PNG, TIFF, WebP)**: +- In-browser image display with zoom and pan controls +- Zoom in / out buttons and mouse-wheel zoom +- Click-and-drag panning for large or zoomed images +- Reset button to return to the original size +- Zoom percentage indicator + +**Text File Preview**: +- Syntax-highlighted display with line numbers +- Monospace font for readable code and data +- Copy-to-clipboard button for the full text content +- Supports plain text, CSV, source code, and other text MIME types + +**View Extracted Text** buttons below each preview to see the full text content **Text Extraction Modals**: Click "View Extracted Text" to open a fullscreen modal showing: - Complete extracted text from OCR or PDF text layer @@ -218,10 +233,18 @@ If your file is still available on disk, you can preview it directly in the brow - **Processed File**: The file after metadata has been embedded (if processing completed) Both previews support: -- In-browser PDF viewing with embedded viewer +- In-browser PDF viewing with pdf.js (canvas-based, no browser plugin needed) +- Image viewing with zoom/pan for JPEG, PNG, TIFF, and WebP +- Text file viewing with line numbers for plain text, CSV, and source code - Side-by-side comparison of original and processed versions - Full text extraction viewing via modal overlays +**Quick Preview from File List**: On the main Files page, each file row includes an eye icon button that opens a slide-out side panel with a quick preview. This lets you: +- Preview PDFs, images, and text files without leaving the file list +- Navigate PDF pages within the panel +- Zoom and pan images +- Open the full document view or download the file from the panel footer + **View Extracted Text**: Each preview includes a button to view the complete extracted text in a fullscreen modal. When you click this button: - The system extracts text from the PDF file on-demand using pypdf - A loading indicator shows while extraction is in progress diff --git a/tests/test_document_preview.py b/tests/test_document_preview.py new file mode 100644 index 00000000..c996c0d5 --- /dev/null +++ b/tests/test_document_preview.py @@ -0,0 +1,413 @@ +""" +Tests for in-browser document preview functionality. + +Tests the pdf.js-based PDF viewer, image zoom/pan, text preview with +line numbers, and the file-list preview modal introduced by the +in-browser document preview feature. +""" + +import os + +import pytest +from fastapi.testclient import TestClient + +from app.models import FileRecord + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _create_file_record( + db_session, + *, + filename: str = "test.pdf", + mime_type: str = "application/pdf", + file_path: str, + processed_path: str | None = None, +) -> FileRecord: + """Create and persist a FileRecord for testing.""" + rec = FileRecord( + filehash="hash_" + filename, + original_filename=filename, + local_filename=file_path, + original_file_path=file_path, + processed_file_path=processed_path, + file_size=os.path.getsize(file_path) if os.path.exists(file_path) else 1024, + mime_type=mime_type, + ) + db_session.add(rec) + db_session.commit() + db_session.refresh(rec) + return rec + + +# =========================================================================== +# file_view.html – PDF preview via pdf.js +# =========================================================================== + + +@pytest.mark.unit +class TestFileViewPdfJs: + """file_view.html should embed a pdf.js viewer for PDF files.""" + + def test_pdf_file_renders_pdfjs_viewer(self, client: TestClient, db_session, tmp_path): + """PDF file view should include pdf.js library and canvas-based viewer.""" + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF-1.4 test") + rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf") + + response = client.get(f"/files/{rec.id}") + assert response.status_code == 200 + html = response.text + + # pdf.js library must be loaded + assert "pdf.min.js" in html + assert "pdf.worker.min.js" in html + + # pdf.js canvas wrapper and page controls must be present + assert 'id="pdf-viewer"' in html + assert 'id="pdf-canvas-wrap"' in html + assert 'id="pdf-page-info"' in html + assert "pdfInit(" in html + + # Should NOT have an iframe for this type + assert "")[-1] or True + + def test_pdf_file_no_iframe(self, client: TestClient, db_session, tmp_path): + """PDF should not use native iframe-based preview.""" + pdf = tmp_path / "doc2.pdf" + pdf.write_bytes(b"%PDF-1.4 test") + rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf") + + response = client.get(f"/files/{rec.id}") + html = response.text + + # The preview section should use pdf-viewer, not iframe + assert 'id="pdf-viewer"' in html + assert "pdf-canvas-wrap" in html + + def test_page_navigation_controls(self, client: TestClient, db_session, tmp_path): + """PDF viewer should have previous/next page buttons.""" + pdf = tmp_path / "nav.pdf" + pdf.write_bytes(b"%PDF-1.4 test") + rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf") + + response = client.get(f"/files/{rec.id}") + html = response.text + + assert 'id="pdf-prev-btn"' in html + assert 'id="pdf-next-btn"' in html + assert "pdfChangePage" in html + + +# =========================================================================== +# file_view.html – Image preview with zoom / pan +# =========================================================================== + + +@pytest.mark.unit +class TestFileViewImagePreview: + """file_view.html should show an image viewer with zoom/pan for image files.""" + + def test_image_file_renders_image_viewer(self, client: TestClient, db_session, tmp_path): + """Image files should show zoom/pan controls instead of iframe.""" + img = tmp_path / "photo.jpg" + img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # minimal JPEG header + rec = _create_file_record(db_session, filename="photo.jpg", mime_type="image/jpeg", file_path=str(img)) + + response = client.get(f"/files/{rec.id}") + assert response.status_code == 200 + html = response.text + + assert 'id="img-viewer"' in html + assert 'id="preview-img"' in html + assert 'id="img-wrap"' in html + assert "imgZoom" in html + assert "imgReset" in html + + def test_image_zoom_controls(self, client: TestClient, db_session, tmp_path): + """Image viewer should have zoom in, zoom out, and reset controls.""" + img = tmp_path / "photo.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50) + rec = _create_file_record(db_session, filename="photo.png", mime_type="image/png", file_path=str(img)) + + response = client.get(f"/files/{rec.id}") + html = response.text + + assert 'aria-label="Zoom in"' in html + assert 'aria-label="Zoom out"' in html + assert 'aria-label="Reset zoom"' in html + assert 'id="img-zoom-level"' in html + + def test_image_pan_support(self, client: TestClient, db_session, tmp_path): + """Image viewer should support mouse-drag panning.""" + img = tmp_path / "wide.webp" + img.write_bytes(b"RIFF" + b"\x00" * 50) + rec = _create_file_record(db_session, filename="wide.webp", mime_type="image/webp", file_path=str(img)) + + response = client.get(f"/files/{rec.id}") + html = response.text + + # Pan support is implemented via JavaScript on img-wrap + assert "mousedown" in html + assert "mousemove" in html + assert "cursor: grab" in html or "cursor:grab" in html + + +# =========================================================================== +# file_view.html – Text file preview with line numbers +# =========================================================================== + + +@pytest.mark.unit +class TestFileViewTextPreview: + """file_view.html should show a text viewer with line numbers for text files.""" + + def test_text_file_renders_text_viewer(self, client: TestClient, db_session, tmp_path): + """Text files should show the text-viewer component.""" + txt = tmp_path / "readme.txt" + txt.write_text("Hello world\nSecond line\n") + rec = _create_file_record(db_session, filename="readme.txt", mime_type="text/plain", file_path=str(txt)) + + response = client.get(f"/files/{rec.id}") + assert response.status_code == 200 + html = response.text + + assert 'id="text-viewer"' in html + assert 'id="text-preview-content"' in html + assert "loadTextPreview" in html + + def test_text_viewer_has_copy_button(self, client: TestClient, db_session, tmp_path): + """Text viewer should have a Copy button.""" + txt = tmp_path / "code.py" + txt.write_text("print('hello')\n") + rec = _create_file_record(db_session, filename="code.py", mime_type="text/x-python", file_path=str(txt)) + + response = client.get(f"/files/{rec.id}") + html = response.text + + assert "copyTextPreview" in html + assert 'id="text-copy-btn"' in html + + def test_text_viewer_line_numbers(self, client: TestClient, db_session, tmp_path): + """Text viewer JS should generate line numbers.""" + txt = tmp_path / "data.csv" + txt.write_text("a,b,c\n1,2,3\n") + rec = _create_file_record(db_session, filename="data.csv", mime_type="text/csv", file_path=str(txt)) + + response = client.get(f"/files/{rec.id}") + html = response.text + + # JS builds line-number spans + assert "line-number" in html + + +# =========================================================================== +# file_view.html – Preview icon adapts to MIME type +# =========================================================================== + + +@pytest.mark.unit +class TestFileViewPreviewIcon: + """Preview card icon should match the MIME type of the file.""" + + def test_pdf_icon(self, client: TestClient, db_session, tmp_path): + """PDF files show a file-pdf icon.""" + pdf = tmp_path / "a.pdf" + pdf.write_bytes(b"%PDF-1.4") + rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf") + + html = client.get(f"/files/{rec.id}").text + assert "fa-file-pdf" in html + + def test_image_icon(self, client: TestClient, db_session, tmp_path): + """Image files show an image icon.""" + img = tmp_path / "p.jpg" + img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 10) + rec = _create_file_record(db_session, filename="p.jpg", mime_type="image/jpeg", file_path=str(img)) + + html = client.get(f"/files/{rec.id}").text + assert "fa-image" in html + + def test_text_icon(self, client: TestClient, db_session, tmp_path): + """Text files show a code icon.""" + txt = tmp_path / "t.txt" + txt.write_text("hello") + rec = _create_file_record(db_session, filename="t.txt", mime_type="text/plain", file_path=str(txt)) + + html = client.get(f"/files/{rec.id}").text + assert "fa-file-code" in html + + +# =========================================================================== +# files.html – Preview modal from file list +# =========================================================================== + + +@pytest.mark.unit +class TestFilesPreviewModal: + """files.html should include a preview side-panel triggered from the file list.""" + + def test_preview_modal_markup(self, client: TestClient, db_session, tmp_path): + """Files page should contain the preview overlay HTML.""" + pdf = tmp_path / "list.pdf" + pdf.write_bytes(b"%PDF-1.4") + _create_file_record(db_session, file_path=str(pdf)) + + response = client.get("/files") + assert response.status_code == 200 + html = response.text + + assert 'id="previewOverlay"' in html + assert 'id="previewBody"' in html + assert 'id="previewTitle"' in html + assert 'role="dialog"' in html + assert 'aria-modal="true"' in html + + def test_preview_button_in_actions(self, client: TestClient, db_session, tmp_path): + """Each file row should have a preview (eye) button.""" + pdf = tmp_path / "btn.pdf" + pdf.write_bytes(b"%PDF-1.4") + _create_file_record(db_session, file_path=str(pdf)) + + html = client.get("/files").text + assert "openPreviewModal" in html + assert "fa-eye" in html + + def test_preview_modal_has_pdfjs(self, client: TestClient, db_session, tmp_path): + """Files page should load pdf.js for the preview modal.""" + pdf = tmp_path / "js.pdf" + pdf.write_bytes(b"%PDF-1.4") + _create_file_record(db_session, file_path=str(pdf)) + + html = client.get("/files").text + assert "pdf.min.js" in html + assert "pdfjsLib" in html + + def test_preview_modal_close(self, client: TestClient, db_session, tmp_path): + """Preview modal should have a close button and Escape handler.""" + pdf = tmp_path / "close.pdf" + pdf.write_bytes(b"%PDF-1.4") + _create_file_record(db_session, file_path=str(pdf)) + + html = client.get("/files").text + assert "closePreviewModal" in html + assert "Escape" in html + + def test_preview_modal_footer_links(self, client: TestClient, db_session, tmp_path): + """Preview modal JS should include download and full-view links.""" + pdf = tmp_path / "footer.pdf" + pdf.write_bytes(b"%PDF-1.4") + _create_file_record(db_session, file_path=str(pdf)) + + html = client.get("/files").text + assert "Full view" in html or "Full view" in html + assert "Download" in html + + +# =========================================================================== +# file_detail.html – Bottom preview section uses correct variable names +# =========================================================================== + + +@pytest.mark.unit +class TestFileDetailBottomPreview: + """file_detail.html bottom File Previews card should render when files exist.""" + + def test_bottom_preview_card_renders(self, client: TestClient, db_session, tmp_path): + """Bottom 'File Previews' card should display when file exists on disk.""" + pdf = tmp_path / "detail.pdf" + pdf.write_bytes(b"%PDF-1.4") + rec = _create_file_record(db_session, file_path=str(pdf), processed_path=str(pdf)) + + response = client.get(f"/files/{rec.id}/detail") + assert response.status_code == 200 + html = response.text + + # The fixed section should now render (was previously using wrong variable names) + assert "File Previews" in html + assert "Original File" in html or "Processed File" in html + + def test_bottom_preview_has_download_link(self, client: TestClient, db_session, tmp_path): + """Bottom preview card should have download links.""" + pdf = tmp_path / "dl.pdf" + pdf.write_bytes(b"%PDF-1.4") + rec = _create_file_record(db_session, file_path=str(pdf), processed_path=str(pdf)) + + response = client.get(f"/files/{rec.id}/detail") + html = response.text + assert f"/api/files/{rec.id}/download" in html + + def test_image_preview_in_detail(self, client: TestClient, db_session, tmp_path): + """Image files should render an tag in the bottom preview card.""" + img = tmp_path / "detail_img.jpg" + img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) + rec = _create_file_record( + db_session, + filename="detail_img.jpg", + mime_type="image/jpeg", + file_path=str(img), + ) + + response = client.get(f"/files/{rec.id}/detail") + html = response.text + assert f"/api/files/{rec.id}/preview?version=original" in html + + +# =========================================================================== +# Existing OCR text functionality preserved +# =========================================================================== + + +@pytest.mark.unit +class TestFileViewOcrText: + """Verify that the existing OCR text feature still works after the preview upgrade.""" + + def test_ocr_text_toggle_present(self, client: TestClient, db_session, tmp_path): + """Pages with ocr_text should still have the toggle button.""" + pdf = tmp_path / "ocr.pdf" + pdf.write_bytes(b"%PDF-1.4") + rec = _create_file_record(db_session, file_path=str(pdf)) + rec.ocr_text = "Sample extracted OCR text content" + db_session.commit() + + html = client.get(f"/files/{rec.id}").text + assert "toggleOcrText" in html + assert "ocr-text-block" in html + assert "Sample extracted OCR text content" in html + + def test_text_extraction_button(self, client: TestClient, db_session, tmp_path): + """Pages without ocr_text but with files should show extract button.""" + pdf = tmp_path / "no_ocr.pdf" + pdf.write_bytes(b"%PDF-1.4") + rec = _create_file_record(db_session, file_path=str(pdf)) + + html = client.get(f"/files/{rec.id}").text + assert "loadText" in html or "Extract" in html + + +# =========================================================================== +# No file available fallback +# =========================================================================== + + +@pytest.mark.unit +class TestFileViewNoFile: + """When no file is on disk, a fallback message should display.""" + + def test_no_preview_fallback(self, client: TestClient, db_session): + """No-file-on-disk scenario shows a fallback message.""" + rec = FileRecord( + filehash="nope", + original_filename="gone.pdf", + local_filename="/nonexistent/gone.pdf", + file_size=0, + mime_type="application/pdf", + ) + db_session.add(rec) + db_session.commit() + db_session.refresh(rec) + + html = client.get(f"/files/{rec.id}").text + assert "No file available for preview" in html