UNPKG

jagran-react-doc-viewer

Version:

A highly customizable React document viewer with support for PDF, DOCX, PPTX, images, and more

1,070 lines (922 loc) • 29.1 kB
# React Document Viewer A highly customizable React document viewer with support for PDF, DOCX, PPTX, images, and more. Built with TypeScript and designed for flexibility and performance. ## Features - šŸ“„ **Multi-format support**: PDF, DOCX, PPTX, images, and text files - šŸŽØ **Fully customizable**: Themes, renderers, and UI components - šŸ”§ **TypeScript**: Full type safety and IntelliSense support - šŸš€ **Performance**: Optimized rendering with lazy loading and prerendering - šŸ“± **Responsive**: Works seamlessly across devices with device-specific configurations - šŸŽÆ **Extensible**: Custom renderers and plugins - šŸŒ™ **Theme support**: Built-in light/dark themes with full customization - šŸ–¼ļø **Thumbnail support**: Document thumbnails and navigation - šŸ“„ **Page-specific rendering**: Start from specific pages - šŸ” **Advanced zoom controls**: Device-specific zoom levels and smooth animations ## Installation ```bash npm install jagran-react-doc-viewer ``` ```bash yarn add jagran-react-doc-viewer ``` ## Quick Start ```tsx import React from 'react'; import { DocViewer } from 'jagran-react-doc-viewer'; function App() { const documents = [ { uri: 'https://example.com/document.pdf', fileName: 'Sample Document.pdf', }, { uri: 'https://example.com/presentation.pptx', fileName: 'Presentation.pptx', }, ]; return ( <div style={{ height: '100vh' }}> <DocViewer documents={documents} /> </div> ); } export default App; ``` ## Comprehensive Examples ### Basic Document Viewer with Multiple Files ```tsx import React, { useState } from 'react'; import { DocViewer, DocumentSource } from 'jagran-react-doc-viewer'; function BasicViewer() { const [documents] = useState<DocumentSource[]>([ { uri: '/documents/annual-report.pdf', fileName: 'Annual Report 2024.pdf', fileType: 'pdf', startPage: 3, // Start from page 3 thumbnail: '/thumbnails/annual-report-thumb.jpg' }, { uri: '/documents/presentation.pptx', fileName: 'Q4 Presentation.pptx', fileType: 'pptx' }, { uri: '/documents/contract.docx', fileName: 'Service Contract.docx', fileType: 'docx' }, { uri: '/images/diagram.png', fileName: 'System Architecture.png', fileType: 'png' } ]); return ( <div style={{ height: '100vh', width: '100%' }}> <DocViewer documents={documents} activeDocument={0} showThumbnail={true} /> </div> ); } ``` ### Advanced Configuration with Custom Styling ```tsx import React from 'react'; import { DocViewer, ViewerConfig, ViewerEvents } from 'jagran-react-doc-viewer'; function AdvancedViewer() { const documents = [ { uri: '/documents/technical-manual.pdf', fileName: 'Technical Manual.pdf', startPage: 1 } ]; const config: ViewerConfig = { theme: 'dark', showToolbar: true, showNavigation: true, allowDownload: true, allowPrint: true, allowFullscreen: true, loadingTimeout: 5000, errorRetryAttempts: 3, // Header configuration header: { visible: true, height: '60px', style: { backgroundColor: '#2c3e50', color: 'white' }, className: 'custom-header' }, // Navigation configuration navigation: { visible: true, position: 'bottom', showThumbnails: true, thumbnailSize: { width: 120, height: 160 }, icons: { prev: 'ā¬…ļø', next: 'āž”ļø', download: 'šŸ’¾', print: 'šŸ–Øļø', fullscreen: 'šŸ”', zoomIn: 'šŸ”+', zoomOut: 'šŸ”-' } }, // Prerender configuration for better performance prerender: { enabled: true, threshold: 10, // 10MB threshold strategy: 'next' // Prerender next document }, // Zoom configuration initialZoom: 1.2, initialZoomByDevice: { mobile: 0.8, tablet: 1.0, desktop: 1.2, tv: 1.5 }, zoomStep: 0.25, minZoom: 0.5, maxZoom: 3.0, // Responsive breakpoints mobileBreakpoint: 768, tabletBreakpoint: 1024, // Mobile-specific behavior mobileBehavior: { collapseToolbar: true, hideNavigation: false, simplifiedControls: true }, // Animation settings animatePageTransition: true, transitionDuration: 300, zoomAnimationDuration: 200, // Spacing and layout pageGap: 20, pagePadding: 15, // Tooltips tooltips: { download: 'Download document', zoom: { in: 'Zoom in', out: 'Zoom out', reset: 'Reset zoom', current: 'Current zoom: {zoom}%' }, navigation: { previous: 'Previous document', next: 'Next document', pageInfo: 'Page {current} of {total}' }, toolbar: { download: 'Download current document', print: 'Print document', fullscreen: 'Toggle fullscreen' } } }; const events: ViewerEvents = { onDocumentLoad: (document) => { console.log('Document loaded:', document.fileName); }, onDocumentError: (error, document) => { console.error('Error loading document:', document.fileName, error); }, onPageChange: (page, totalPages) => { console.log(`Page ${page} of ${totalPages}`); }, onZoomChange: (zoom) => { console.log('Zoom level:', zoom); }, onFullscreenToggle: (isFullscreen) => { console.log('Fullscreen:', isFullscreen); } }; return ( <DocViewer documents={documents} config={config} events={events} className="advanced-doc-viewer" style={{ height: '100vh', border: '1px solid #ddd' }} /> ); } ``` ### Custom Theme Example ```tsx import React from 'react'; import { DocViewer, ViewerTheme } from 'jagran-react-doc-viewer'; function ThemedViewer() { const customTheme: ViewerTheme = { primary: '#3498db', secondary: '#2ecc71', background: '#ecf0f1', surface: '#ffffff', text: '#2c3e50', textSecondary: '#7f8c8d', border: '#bdc3c7', error: '#e74c3c', success: '#27ae60' }; const documents = [ { uri: '/docs/styled-document.pdf', fileName: 'Styled Document.pdf' } ]; return ( <DocViewer documents={documents} config={{ theme: customTheme }} style={{ height: '100vh', '--doc-viewer-primary': customTheme.primary, '--doc-viewer-background': customTheme.background } as React.CSSProperties} /> ); } ``` ### Using Refs for Programmatic Control ```tsx import React, { useRef, useCallback } from 'react'; import { DocViewer, DocViewerRef } from 'jagran-react-doc-viewer'; function ControlledViewer() { const viewerRef = useRef<DocViewerRef>(null); const documents = [ { uri: '/docs/manual.pdf', fileName: 'User Manual.pdf' }, { uri: '/docs/guide.pdf', fileName: 'Quick Start Guide.pdf' } ]; const handleNextDocument = useCallback(() => { viewerRef.current?.nextDocument(); }, []); const handlePreviousDocument = useCallback(() => { viewerRef.current?.previousDocument(); }, []); const handleZoomIn = useCallback(() => { viewerRef.current?.incrementZoom(); }, []); const handleZoomOut = useCallback(() => { viewerRef.current?.decrementZoom(); }, []); const handleDownload = useCallback(() => { viewerRef.current?.downloadCurrent(); }, []); const handlePrint = useCallback(() => { viewerRef.current?.printCurrent(); }, []); const goToSpecificDocument = useCallback((index: number) => { viewerRef.current?.setActiveDocument(index); }, []); return ( <div> {/* Custom toolbar */} <div style={{ padding: '10px', borderBottom: '1px solid #ddd' }}> <button onClick={handlePreviousDocument}>← Previous</button> <button onClick={handleNextDocument}>Next →</button> <button onClick={handleZoomIn}>Zoom In</button> <button onClick={handleZoomOut}>Zoom Out</button> <button onClick={handleDownload}>Download</button> <button onClick={handlePrint}>Print</button> <button onClick={() => goToSpecificDocument(0)}>Go to First</button> <button onClick={() => goToSpecificDocument(1)}>Go to Second</button> </div> <DocViewer ref={viewerRef} documents={documents} config={{ showToolbar: false }} // Hide default toolbar style={{ height: 'calc(100vh - 60px)' }} /> </div> ); } ``` ### Custom Loading and Error Components ```tsx import React from 'react'; import { DocViewer, LoadingRendererProps, ErrorRendererProps } from 'jagran-react-doc-viewer'; // Custom loading component const CustomLoadingComponent: React.FC<LoadingRendererProps> = ({ document, fileName, progress }) => ( <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', backgroundColor: '#f8f9fa' }}> <div className="spinner" style={{ width: '40px', height: '40px', border: '4px solid #e3e3e3', borderTop: '4px solid #3498db', borderRadius: '50%', animation: 'spin 1s linear infinite' }} /> <h3 style={{ marginTop: '20px', color: '#2c3e50' }}> Loading {fileName || 'document'}... </h3> {progress && ( <div style={{ width: '200px', backgroundColor: '#e0e0e0', borderRadius: '10px', marginTop: '10px' }}> <div style={{ width: `${progress}%`, height: '8px', backgroundColor: '#3498db', borderRadius: '10px', transition: 'width 0.3s ease' }} /> </div> )} </div> ); // Custom error component const CustomErrorComponent: React.FC<ErrorRendererProps> = ({ error, document, onRetry }) => ( <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', backgroundColor: '#fff5f5', color: '#e53e3e' }}> <div style={{ fontSize: '48px', marginBottom: '20px' }}>āš ļø</div> <h3>Failed to load document</h3> <p style={{ textAlign: 'center', maxWidth: '400px', margin: '10px 0' }}> {document.fileName || 'Unknown document'} could not be loaded. </p> <p style={{ fontSize: '14px', color: '#666', marginBottom: '20px' }}> Error: {error.message} </p> {onRetry && ( <button onClick={onRetry} style={{ padding: '10px 20px', backgroundColor: '#3498db', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }} > Try Again </button> )} </div> ); function ViewerWithCustomComponents() { const documents = [ { uri: '/docs/document.pdf', fileName: 'Important Document.pdf' } ]; return ( <DocViewer documents={documents} loadingRenderer={CustomLoadingComponent} errorRenderer={CustomErrorComponent} config={{ loadingTimeout: 10000, errorRetryAttempts: 5 }} /> ); } ``` ## API Reference ### DocViewer Props | Prop | Type | Default | Description | |------|------|---------|-------------| | `documents` | `DocumentSource[]` | `[]` | Array of documents to display | | `activeDocument` | `number` | `0` | Index of initially active document | | `startIndex` | `number` | `0` | Index to start displaying documents from | | `config` | `ViewerConfig` | `{}` | Viewer configuration options | | `events` | `ViewerEvents` | `{}` | Event handlers | | `customRenderers` | `CustomRenderer[]` | `[]` | Custom renderers for specific file types | | `loadingRenderer` | `ComponentType<LoadingRendererProps>` | - | Custom loading component | | `errorRenderer` | `ComponentType<ErrorRendererProps>` | - | Custom error component | | `className` | `string` | - | CSS class name | | `style` | `React.CSSProperties` | - | Inline styles | | `zoomLevel` | `number` | - | External zoom control | | `onZoomChange` | `(zoomLevel: number) => number` | - | Zoom change handler | | `allowScreenshot` | `boolean` | `false` | Allow screenshot functionality | | `loading` | `boolean` | - | External loading state | | `showThumbnail` | `boolean` | `false` | Show document thumbnails | | `onLoadingChange` | `(loading: boolean) => void` | - | Loading state change handler | ### DocumentSource Interface ```tsx interface DocumentSource { uri: string; // Document URL or path fileName?: string; // Display name for the document fileType?: string; // File type (pdf, docx, pptx, etc.) startPage?: number; // Page to start rendering from (1-based) thumbnail?: string; // Thumbnail image URL } ``` ### ViewerConfig Interface ```tsx interface ViewerConfig { theme?: 'light' | 'dark' | ViewerTheme; showToolbar?: boolean; showNavigation?: boolean; allowDownload?: boolean; allowPrint?: boolean; allowFullscreen?: boolean; loadingTimeout?: number; errorRetryAttempts?: number; // Layout configuration header?: HeaderConfig; navigation?: NavigationConfig; prerender?: PrerenderConfig; // Zoom configuration initialZoom?: number; initialZoomByDevice?: { mobile?: number; tablet?: number; ipad?: number; desktop?: number; tv?: number; }; zoomStep?: number; minZoom?: number; maxZoom?: number; // Responsive configuration mobileBreakpoint?: number; tabletBreakpoint?: number; mobileBehavior?: { collapseToolbar?: boolean; hideNavigation?: boolean; simplifiedControls?: boolean; }; // Animation configuration animatePageTransition?: boolean; transitionDuration?: number; zoomAnimationDuration?: number; // Spacing configuration pageGap?: number; pagePadding?: number; // Tooltip configuration tooltips?: { download?: string; zoom?: { in?: string; out?: string; reset?: string; current?: string; }; navigation?: { previous?: string; next?: string; pageInfo?: string; }; toolbar?: { download?: string; print?: string; fullscreen?: string; customActions?: Record<string, string>; }; error?: { retry?: string; generic?: string; }; }; } ``` ### Custom Renderers Create custom renderers for specific file types: ```tsx import React from 'react'; import { DocViewer, CustomRenderer, RendererProps } from 'jagran-react-doc-viewer'; // Custom PDF renderer with enhanced features const EnhancedPDFRenderer: React.FC<RendererProps> = ({ document, config, events, externalZoom, onZoomUpdate }) => { const [currentPage, setCurrentPage] = React.useState(1); const [totalPages, setTotalPages] = React.useState(0); return ( <div className="enhanced-pdf-renderer"> <div className="pdf-header"> <h3>Enhanced PDF: {document.fileName}</h3> <div className="page-info"> Page {currentPage} of {totalPages} </div> </div> <div className="pdf-content"> {/* Your custom PDF rendering logic here */} <iframe src={`${document.uri}#page=${document.startPage || 1}`} style={{ width: '100%', height: '100%', border: 'none' }} title={document.fileName} /> </div> </div> ); }; const customRenderers: CustomRenderer[] = [ { fileTypes: ['.pdf'], component: EnhancedPDFRenderer, priority: 1, handlesOwnZoom: true } ]; <DocViewer documents={documents} customRenderers={customRenderers} /> ``` ## Supported File Types - **PDF**: `.pdf` - Full-featured PDF rendering with zoom, navigation, and text selection - **Microsoft Word**: `.docx` - Document rendering with formatting preservation - **Microsoft PowerPoint**: `.pptx` - Presentation slides with navigation - **Images**: `.jpg`, `.jpeg`, `.png`, `.gif`, `.bmp`, `.webp` - High-quality image display with zoom - **Text Files**: `.txt`, `.md`, `.json`, `.xml`, `.csv`, `.js`, `.ts`, `.css`, `.html` - Syntax-highlighted text rendering ## Real-World Integration Examples ### Enterprise Document Management System ```tsx import React, { useState, useCallback } from 'react'; import { DocViewer, DocumentSource, ViewerEvents } from 'jagran-react-doc-viewer'; function EnterpriseDocumentViewer() { const [documents] = useState<DocumentSource[]>([ { uri: '/api/documents/contracts/service-agreement-2024.pdf', fileName: 'Service Agreement 2024.pdf', fileType: 'pdf', startPage: 1 }, { uri: '/api/documents/reports/quarterly-report-q4.docx', fileName: 'Q4 Financial Report.docx', fileType: 'docx' } ]); const [auditLog, setAuditLog] = useState<string[]>([]); const handleDocumentAccess = useCallback((document: DocumentSource) => { const logEntry = `${new Date().toISOString()}: User accessed ${document.fileName}`; setAuditLog(prev => [...prev, logEntry]); // Send to analytics service fetch('/api/analytics/document-access', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ documentUri: document.uri, fileName: document.fileName, timestamp: new Date().toISOString() }) }); }, []); const events: ViewerEvents = { onDocumentLoad: handleDocumentAccess, onDocumentError: (error, document) => { console.error(`Failed to load ${document.fileName}:`, error); }, onPageChange: (page, totalPages) => { const progress = (page / totalPages) * 100; if (progress > 50) { console.log('Document substantially read'); } } }; const enterpriseConfig = { theme: 'light' as const, showToolbar: true, allowDownload: true, allowPrint: true, header: { visible: true, customContent: ( <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}> <img src="/company-logo.png" alt="Company" height="30" /> <span style={{ fontWeight: 'bold' }}>Enterprise Document Viewer</span> </div> ), style: { backgroundColor: '#1e3a8a', color: 'white' } }, initialZoomByDevice: { mobile: 0.7, tablet: 0.9, desktop: 1.0 } }; return ( <div style={{ height: '100vh' }}> <DocViewer documents={documents} config={enterpriseConfig} events={events} className="enterprise-doc-viewer" /> </div> ); } ``` ### E-Learning Platform Integration ```tsx import React, { useState, useEffect } from 'react'; import { DocViewer, DocumentSource } from 'jagran-react-doc-viewer'; function ELearningDocumentViewer() { const [lessonDocuments, setLessonDocuments] = useState<DocumentSource[]>([]); const [readingProgress, setReadingProgress] = useState<Record<string, number>>({}); const [completedDocuments, setCompletedDocuments] = useState<Set<string>>(new Set()); const handlePageChange = (page: number, totalPages: number, documentIndex: number) => { const progress = (page / totalPages) * 100; const docId = lessonDocuments[documentIndex]?.fileName || ''; setReadingProgress(prev => ({ ...prev, [docId]: progress })); if (progress > 90) { setCompletedDocuments(prev => new Set([...prev, docId])); } }; const learningConfig = { theme: 'light' as const, showToolbar: true, allowDownload: true, allowPrint: false, initialZoom: 1.1, pageGap: 30, pagePadding: 20, navigation: { visible: true, position: 'bottom' as const, showThumbnails: true, thumbnailSize: { width: 100, height: 140 } }, animatePageTransition: true, transitionDuration: 400 }; return ( <div style={{ height: '100vh', display: 'flex' }}> <div style={{ width: '300px', backgroundColor: '#f8fafc', padding: '20px' }}> <h3>Lesson Documents</h3> {lessonDocuments.map((doc, index) => { const progress = readingProgress[doc.fileName || ''] || 0; const isCompleted = completedDocuments.has(doc.fileName || ''); return ( <div key={index} style={{ marginBottom: '15px', padding: '10px', backgroundColor: 'white', borderRadius: '8px', border: isCompleted ? '2px solid #10b981' : '1px solid #e2e8f0' }}> <span style={{ fontWeight: '500' }}>{doc.fileName}</span> <div style={{ width: '100%', height: '4px', backgroundColor: '#e5e7eb', borderRadius: '2px', marginTop: '5px' }}> <div style={{ width: `${progress}%`, height: '100%', backgroundColor: isCompleted ? '#10b981' : '#3b82f6' }} /> </div> </div> ); })} </div> <div style={{ flex: 1 }}> <DocViewer documents={lessonDocuments} config={learningConfig} events={{ onPageChange: handlePageChange }} /> </div> </div> ); } ``` ## Performance Optimization ### Lazy Loading and Prerendering ```tsx import React from 'react'; import { DocViewer } from 'jagran-react-doc-viewer'; function OptimizedViewer() { const documents = [ { uri: '/docs/large-document.pdf', fileName: 'Large Document.pdf' }, { uri: '/docs/presentation.pptx', fileName: 'Presentation.pptx' } ]; const performanceConfig = { // Enable prerendering for better performance prerender: { enabled: true, threshold: 5, // Only prerender files smaller than 5MB strategy: 'next' as const // Prerender next document in queue }, // Optimize loading loadingTimeout: 15000, errorRetryAttempts: 2, // Smooth animations animatePageTransition: true, transitionDuration: 250, zoomAnimationDuration: 150 }; return ( <DocViewer documents={documents} config={performanceConfig} loading={false} // Control external loading state onLoadingChange={(loading) => { console.log('Loading state changed:', loading); }} /> ); } ``` ### Mobile-First Responsive Design ```tsx import React from 'react'; import { DocViewer } from 'jagran-react-doc-viewer'; function ResponsiveViewer() { const documents = [ { uri: '/docs/mobile-optimized.pdf', fileName: 'Mobile Document.pdf' } ]; const responsiveConfig = { // Device-specific zoom levels initialZoomByDevice: { mobile: 0.6, // Smaller zoom for mobile screens tablet: 0.8, // Medium zoom for tablets ipad: 0.9, // Slightly larger for iPads desktop: 1.0, // Standard zoom for desktop tv: 1.4 // Larger zoom for TV displays }, // Responsive breakpoints mobileBreakpoint: 768, tabletBreakpoint: 1024, // Mobile-specific behavior mobileBehavior: { collapseToolbar: true, // Collapse toolbar on mobile hideNavigation: false, // Keep navigation visible simplifiedControls: true // Use simplified control set }, // Touch-friendly navigation navigation: { visible: true, position: 'bottom' as const, style: { padding: '15px', // Larger touch targets fontSize: '16px' // Readable text size } } }; return ( <DocViewer documents={documents} config={responsiveConfig} style={{ height: '100vh', // CSS custom properties for responsive design '--mobile-padding': '10px', '--desktop-padding': '20px' } as React.CSSProperties} /> ); } ``` ## Development ### Prerequisites - Node.js 16+ - npm or yarn ### Setup ```bash # Clone the repository git clone https://github.com/jnmamp/jagran-pdf-viewer.git cd jagran-pdf-viewer # Install dependencies npm install # Start development server npm run dev # Build for production npm run build # Run tests npm test # Lint code npm run lint # Type checking npm run type-check ``` ### Project Structure ``` src/ ā”œā”€ā”€ components/ # React components │ ā”œā”€ā”€ DocViewer.tsx # Main viewer component │ └── ... # Other UI components ā”œā”€ā”€ hooks/ # Custom React hooks ā”œā”€ā”€ renderers/ # File type renderers │ ā”œā”€ā”€ PDFRenderer.tsx │ ā”œā”€ā”€ ImageRenderer.tsx │ └── ... ā”œā”€ā”€ styles/ # CSS styles ā”œā”€ā”€ theme/ # Theme definitions ā”œā”€ā”€ types/ # TypeScript type definitions ā”œā”€ā”€ utils/ # Utility functions └── index.ts # Main entry point ``` ### Building Custom Renderers ```tsx import React from 'react'; import { RendererProps } from 'jagran-react-doc-viewer'; // Example: Custom CSV renderer export const CSVRenderer: React.FC<RendererProps> = ({ document, config, loading, onLoadingChange }) => { const [csvData, setCsvData] = React.useState<string[][]>([]); const [headers, setHeaders] = React.useState<string[]>([]); React.useEffect(() => { onLoadingChange?.(true); fetch(document.uri) .then(response => response.text()) .then(text => { const lines = text.split('\n'); const headers = lines[0].split(','); const data = lines.slice(1).map(line => line.split(',')); setHeaders(headers); setCsvData(data); }) .finally(() => { onLoadingChange?.(false); }); }, [document.uri, onLoadingChange]); if (loading) { return <div>Loading CSV data...</div>; } return ( <div style={{ padding: '20px', overflow: 'auto' }}> <h3>{document.fileName}</h3> <table style={{ width: '100%', borderCollapse: 'collapse' }}> <thead> <tr> {headers.map((header, index) => ( <th key={index} style={{ border: '1px solid #ddd', padding: '8px', backgroundColor: '#f5f5f5' }}> {header} </th> ))} </tr> </thead> <tbody> {csvData.map((row, rowIndex) => ( <tr key={rowIndex}> {row.map((cell, cellIndex) => ( <td key={cellIndex} style={{ border: '1px solid #ddd', padding: '8px' }}> {cell} </td> ))} </tr> ))} </tbody> </table> </div> ); }; // Register the custom renderer const customRenderers = [ { fileTypes: ['.csv'], component: CSVRenderer, priority: 1 } ]; ``` ## Contributing 1. Fork the repository 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 4. Push to the branch (`git push origin feature/amazing-feature`) 5. Open a Pull Request ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. ## Support - šŸ“§ **Issues**: [GitHub Issues](https://github.com/jnmamp/jagran-pdf-viewer/issues) - šŸ“– **Documentation**: [GitHub Wiki](https://github.com/jnmamp/jagran-pdf-viewer/wiki) ## Acknowledgments - Built with [PDF.js](https://mozilla.github.io/pdf.js/) for PDF rendering - Uses [Mammoth.js](https://github.com/mwilliamson/mammoth.js) for DOCX support - Inspired by modern document viewers and React best practices