UNPKG

ng-pdf-renderer

Version:

A modern, zero-configuration PDF viewer for Angular applications with enhanced error handling, auto-fit, text selection, and responsive design

1,128 lines (1,116 loc) 85.4 kB
import * as i0 from '@angular/core'; import { Injectable, inject, EventEmitter, Output, Input, Component, signal, ViewChild } from '@angular/core'; import * as i1 from '@angular/common'; import { CommonModule } from '@angular/common'; import { BehaviorSubject, Subject, takeUntil } from 'rxjs'; import * as pdfjsLib from 'pdfjs-dist'; import * as i2 from '@angular/forms'; import { FormsModule } from '@angular/forms'; /** * Service for global configuration of NgPdfRenderer * Allows application-wide settings to be applied */ class NgPdfRendererConfigService { constructor() { this._config = {}; } /** * Get the current configuration */ get config() { return this._config; } /** * Set the configuration for NgPdfRenderer * @param config Configuration options */ setConfig(config) { this._config = { ...this._config, ...config }; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: NgPdfRendererConfigService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: NgPdfRendererConfigService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: NgPdfRendererConfigService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); /** * Service handling PDF operations using PDF.js * Provides methods to load, navigate, and manipulate PDF documents */ class PdfService { constructor() { // BehaviorSubjects to track PDF state (these emit current value on subscription) this.pdfDocumentSubject = new BehaviorSubject(null); // Holds the PDF document object this.pdfDocument$ = this.pdfDocumentSubject.asObservable(); // Observable for components to subscribe to this.currentPageSubject = new BehaviorSubject(1); // Current page being viewed this.currentPage$ = this.currentPageSubject.asObservable(); this.totalPagesSubject = new BehaviorSubject(0); // Total number of pages in the document this.totalPages$ = this.totalPagesSubject.asObservable(); this.zoomSubject = new BehaviorSubject(1); // Current zoom level (1 = 100%) this.zoom$ = this.zoomSubject.asObservable(); this.rotationSubject = new BehaviorSubject(0); // Current rotation in degrees this.rotation$ = this.rotationSubject.asObservable(); // Properties needed for link service this._pdfDocument = null; this._viewer = null; // Inject the configuration service this.configService = inject(NgPdfRendererConfigService); // Automatically configure the worker source this.configureWorkerSource(); // Initialize link service this.linkService = { setDocument: (pdfDocument) => { this._pdfDocument = pdfDocument; }, setViewer: (viewer) => { this._viewer = viewer; }, navigateTo: (dest) => { //console.log('Navigate to:', dest); if (dest && typeof dest === 'object' && dest.length > 0) { if (dest[0] && typeof dest[0] === 'object' && 'num' in dest[0]) { // Navigate to page const pageNumber = dest[0].num + 1; this.setCurrentPage(pageNumber); if (this._viewer && this._viewer.scrollPageIntoView) { this._viewer.scrollPageIntoView({ pageNumber }); } } } }, getDestinationHash: (dest) => { return `page=${dest}`; }, getAnchorUrl: (hash) => { return `#${hash}`; } }; } /** * Gets the link service for handling annotations * @returns The link service instance */ getLinkService() { return this.linkService; } /** * Get the current PDF document * @returns The current PDF document or null if none is loaded */ getCurrentDocument() { return this.pdfDocumentSubject.value; } /** * Clear the current document and reset state * Used when switching between PDFs */ clearDocument() { // console.log('Clearing PDF document from service...'); // Reset all state to initial values this.pdfDocumentSubject.next(null); this.currentPageSubject.next(1); this.totalPagesSubject.next(0); this.zoomSubject.next(1); this.rotationSubject.next(0); // Clear link service this._pdfDocument = null; this._viewer = null; // console.log('PDF document cleared from service'); } /** * Configures the PDF.js worker source automatically * This eliminates the need for users to manually copy worker files */ configureWorkerSource() { // First, check if workerSrc is already set if (pdfjsLib.GlobalWorkerOptions.workerSrc) { //console.log('Worker already set:', pdfjsLib.GlobalWorkerOptions.workerSrc); return; } // If workerSrc is provided in the config, use it if (this.configService.config.workerSrc) { //console.log(`Setting worker from config: ${this.configService.config.workerSrc}`); pdfjsLib.GlobalWorkerOptions.workerSrc = this.configService.config.workerSrc; return; } // Get the current PDF.js version const pdfVersion = pdfjsLib.version; // Detect major version to determine worker file name const majorVersion = parseInt(pdfVersion.split('.')[0]); // PDF.js v4+ uses .mjs files, v3 and below use .min.js const workerFile = majorVersion >= 4 ? 'pdf.worker.mjs' : 'pdf.worker.min.js'; // CDN path to the worker file (using unpkg CDN) const cdnWorkerSrc = `https://unpkg.com/pdfjs-dist@${pdfVersion}/build/${workerFile}`; //console.log(`PDF.js version ${pdfVersion} detected (v${majorVersion})`); //console.log(`Using PDF.js worker from CDN: ${cdnWorkerSrc}`); pdfjsLib.GlobalWorkerOptions.workerSrc = cdnWorkerSrc; } /** * Loads a PDF document from a URL or binary data * @param src URL or binary data of the PDF * @returns Promise resolving to the loaded PDF document */ async loadDocument(src) { try { //console.log('PDF.js worker source:', pdfjsLib.GlobalWorkerOptions.workerSrc || 'NOT SET'); //console.log(`Loading document from: ${typeof src === 'string' ? src : 'Binary data'}`); // Create a PDF loading task const loadingTask = pdfjsLib.getDocument(src); // Add progress tracking loadingTask.onProgress = (progressData) => { const progress = (progressData.loaded / progressData.total) * 100; //console.log(`Loading PDF: ${progress.toFixed(2)}%`); }; // Wait for the document to load //console.log('Waiting for PDF document to load...'); const pdfDocument = await loadingTask.promise; //console.log(`PDF document loaded with ${pdfDocument.numPages} pages`); // Update subjects with the loaded document info this.pdfDocumentSubject.next(pdfDocument); this.totalPagesSubject.next(pdfDocument.numPages); this.currentPageSubject.next(1); // Reset to first page // Configure link service with the document this.linkService.setDocument(pdfDocument); this.linkService.setViewer({ scrollPageIntoView: ({ pageNumber }) => { this.setCurrentPage(pageNumber); } }); return pdfDocument; } catch (error) { //console.error('Error loading PDF document:', error); throw error; // Re-throw to allow component to handle it } } /** * Sets the current page to display * @param pageNumber The page number to display (1-based index) */ setCurrentPage(pageNumber) { const totalPages = this.totalPagesSubject.value; // Ensure page number is within valid range if (pageNumber >= 1 && pageNumber <= totalPages) { this.currentPageSubject.next(pageNumber); } } /** * Navigate to the next page if available */ nextPage() { const currentPage = this.currentPageSubject.value; const totalPages = this.totalPagesSubject.value; if (currentPage < totalPages) { this.currentPageSubject.next(currentPage + 1); } } /** * Navigate to the previous page if available */ previousPage() { const currentPage = this.currentPageSubject.value; if (currentPage > 1) { this.currentPageSubject.next(currentPage - 1); } } /** * Set the zoom level for the PDF * @param zoom The zoom level (1 = 100%) */ setZoom(zoom) { this.zoomSubject.next(zoom); } /** * Increase zoom by 20% */ zoomIn() { const currentZoom = this.zoomSubject.value; this.zoomSubject.next(currentZoom * 1.2); } /** * Decrease zoom by 20% */ zoomOut() { const currentZoom = this.zoomSubject.value; this.zoomSubject.next(currentZoom / 1.2); } /** * Rotate the PDF by a specified number of degrees * @param degrees The degrees to rotate (positive = clockwise, negative = counterclockwise) */ rotate(degrees) { const currentRotation = this.rotationSubject.value; // Calculate new rotation and keep it within 0-359 degrees let newRotation = (currentRotation + degrees) % 360; if (newRotation < 0) { newRotation += 360; } this.rotationSubject.next(newRotation); } /** * Get the document outline (bookmarks) * @returns Promise resolving to the outline structure or empty array */ getOutline() { const pdfDocument = this.pdfDocumentSubject.value; if (!pdfDocument) { return Promise.resolve([]); } // Get outline or return empty array if not available return pdfDocument.getOutline() || Promise.resolve([]); } /** * Generate a thumbnail for a specific page * @param pageNumber The page number to generate thumbnail for * @param scale The scale for the thumbnail (smaller = faster) * @returns Promise resolving to data URL of the thumbnail */ async generateThumbnail(pageNumber, scale = 0.2) { const pdfDocument = this.pdfDocumentSubject.value; if (!pdfDocument) { return ''; } try { // Get the page object from PDF document const page = await pdfDocument.getPage(pageNumber); // Create a viewport with the specified scale (smaller for thumbnails) const viewport = page.getViewport({ scale }); // Create an off-screen canvas for rendering const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); canvas.height = viewport.height; canvas.width = viewport.width; // Render the page to the canvas await page.render({ canvasContext: context, viewport }).promise; // Convert canvas to data URL return canvas.toDataURL(); } catch (error) { //console.error('Error generating thumbnail:', error); return ''; } } /** * Search for text in the PDF document * @param text The text to search for * @returns Promise resolving to an array of search results */ async search(text) { const pdfDocument = this.pdfDocumentSubject.value; if (!pdfDocument) { //console.warn('No PDF document loaded'); return []; } const results = []; const totalPages = pdfDocument.numPages; // Search through each page for (let pageNum = 1; pageNum <= totalPages; pageNum++) { try { const page = await pdfDocument.getPage(pageNum); const textContent = await page.getTextContent(); const textItems = textContent.items; // Search through text items on the page for (let i = 0; i < textItems.length; i++) { const item = textItems[i]; if (item.str.toLowerCase().includes(text.toLowerCase())) { results.push({ pageNumber: pageNum, text: item.str, transform: item.transform, width: item.width, height: item.height }); } } } catch (error) { //console.error(`Error searching page ${pageNum}:`, error); } } return results; } /** * Download the PDF document */ async downloadPdf() { const pdfDocument = this.pdfDocumentSubject.value; if (!pdfDocument) { return; } try { // Get the binary data of the PDF const url = pdfDocument.getData ? await pdfDocument.getData() : null; if (url) { // Create a blob from binary data const blob = new Blob([url], { type: 'application/pdf' }); const blobUrl = URL.createObjectURL(blob); // Create a temporary link element to trigger download const link = document.createElement('a'); link.href = blobUrl; // Try to get filename from PDF metadata or use default let filename = 'document.pdf'; try { const metadata = await pdfDocument.getMetadata(); if (metadata.info && metadata.info.Title) { filename = `${metadata.info.Title}.pdf`; } } catch (error) { //console.error('Error getting PDF metadata:', error); } // Set download attribute and click the link link.download = filename; link.style.display = 'none'; document.body.appendChild(link); link.click(); // Clean up DOM and revoke blob URL document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } else { //console.error('Unable to download: PDF data not available'); } } catch (error) { //console.error('Error downloading PDF:', error); } } /** * Print the PDF document */ async printPdf() { const pdfDocument = this.pdfDocumentSubject.value; if (!pdfDocument) { return; } try { // Create hidden iframe to load PDF for printing const printIframe = document.createElement('iframe'); printIframe.style.position = 'absolute'; printIframe.style.top = '-1000px'; printIframe.style.left = '-1000px'; printIframe.style.width = '0'; printIframe.style.height = '0'; document.body.appendChild(printIframe); // Get PDF data and create a blob URL const data = await pdfDocument.getData(); const blob = new Blob([data], { type: 'application/pdf' }); const blobUrl = URL.createObjectURL(blob); // Load PDF into iframe printIframe.src = blobUrl; // Once iframe is loaded, trigger print dialog printIframe.onload = () => { try { if (printIframe.contentWindow) { // Focus and print the iframe content printIframe.contentWindow.focus(); printIframe.contentWindow.print(); } } catch (error) { //console.error('Error printing PDF:', error); // Fallback: open in new tab for user to print window.open(blobUrl, '_blank'); } finally { // Clean up resources (after delay to allow for printing) setTimeout(() => { document.body.removeChild(printIframe); URL.revokeObjectURL(blobUrl); }, 1000); } }; } catch (error) { //console.error('Error setting up PDF print:', error); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: PdfService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: PdfService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: PdfService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); /** * Component for PDF controls (navigation, zoom, etc.) */ class PdfControlsComponent { constructor() { // Input properties for control configuration this.currentPage = 1; // Current page being displayed this.totalPages = 0; // Total pages in document this.zoom = 1; // Current zoom level this.rotation = 0; // Current rotation in degrees // Control visibility options this.showNavigation = true; // Show page navigation this.showZoomControls = true; // Show zoom controls this.showRotationControls = true; // Show rotation controls this.showDownloadButton = true; // Show download button this.showPrintButton = true; // Show print button this.showSearchBar = true; // Show search functionality this.showThumbnails = false; // Show thumbnails panel this.showOutline = false; // Show outline/bookmarks panel // Output events this.pageChange = new EventEmitter(); // Page changed this.zoomChange = new EventEmitter(); // Zoom changed this.rotationChange = new EventEmitter(); // Rotation changed this.download = new EventEmitter(); // Download requested this.print = new EventEmitter(); // Print requested this.search = new EventEmitter(); // Search requested this.toggleThumbnails = new EventEmitter(); // Toggle thumbnails this.toggleOutline = new EventEmitter(); // Toggle outline } /** * Navigate to first page */ onFirstPage() { this.pageChange.emit(1); } /** * Navigate to previous page */ onPreviousPage() { if (this.currentPage > 1) { this.pageChange.emit(this.currentPage - 1); } } /** * Navigate to next page */ onNextPage() { if (this.currentPage < this.totalPages) { this.pageChange.emit(this.currentPage + 1); } } /** * Navigate to last page */ onLastPage() { this.pageChange.emit(this.totalPages); } /** * Handle direct page number input * @param page The new page number */ onPageInputChange(page) { if (page >= 1 && page <= this.totalPages) { this.pageChange.emit(page); } } /** * Increase zoom by 20% */ onZoomIn() { this.zoomChange.emit(this.zoom * 1.2); } /** * Decrease zoom by 20% */ onZoomOut() { this.zoomChange.emit(this.zoom / 1.2); } /** * Handle zoom dropdown selection * @param zoom The selected zoom level */ onZoomSelect(zoom) { this.zoomChange.emit(parseFloat(zoom.toString())); } /** * Rotate counterclockwise by 90 degrees */ onRotateLeft() { this.rotationChange.emit(-90); } /** * Rotate clockwise by 90 degrees */ onRotateRight() { this.rotationChange.emit(90); } /** * Trigger document download */ onDownload() { this.download.emit(); } /** * Trigger document printing */ onPrint() { this.print.emit(); } /** * Execute search if text is provided * @param text The text to search for */ onSearch(text) { if (text.trim()) { this.search.emit(text); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: PdfControlsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.8", type: PdfControlsComponent, isStandalone: true, selector: "ng-pdf-controls", inputs: { currentPage: "currentPage", totalPages: "totalPages", zoom: "zoom", rotation: "rotation", showNavigation: "showNavigation", showZoomControls: "showZoomControls", showRotationControls: "showRotationControls", showDownloadButton: "showDownloadButton", showPrintButton: "showPrintButton", showSearchBar: "showSearchBar", showThumbnails: "showThumbnails", showOutline: "showOutline" }, outputs: { pageChange: "pageChange", zoomChange: "zoomChange", rotationChange: "rotationChange", download: "download", print: "print", search: "search", toggleThumbnails: "toggleThumbnails", toggleOutline: "toggleOutline" }, ngImport: i0, template: ` <div class="pdf-controls"> <!-- Page navigation controls --> <div class="pdf-navigation" *ngIf="showNavigation"> <button (click)="onFirstPage()" [disabled]="currentPage <= 1">First</button> <button (click)="onPreviousPage()" [disabled]="currentPage <= 1">Previous</button> <span class="page-info"> <!-- Page input with two-way binding --> <input type="number" [ngModel]="currentPage" (ngModelChange)="onPageInputChange($event)" min="1" [max]="totalPages"> / {{ totalPages }} </span> <button (click)="onNextPage()" [disabled]="currentPage >= totalPages">Next</button> <button (click)="onLastPage()" [disabled]="currentPage >= totalPages">Last</button> </div> <!-- Zoom controls --> <div class="pdf-zoom" *ngIf="showZoomControls"> <button (click)="onZoomOut()">-</button> <span>{{ (zoom * 100).toFixed(0) }}%</span> <button (click)="onZoomIn()">+</button> <select [ngModel]="zoom" (ngModelChange)="onZoomSelect($event)"> <option [value]="0.5">50%</option> <option [value]="0.75">75%</option> <option [value]="1">100%</option> <option [value]="1.25">125%</option> <option [value]="1.5">150%</option> <option [value]="2">200%</option> </select> </div> <!-- Rotation controls --> <div class="pdf-rotation" *ngIf="showRotationControls"> <button (click)="onRotateLeft()">↺</button> <button (click)="onRotateRight()">↻</button> </div> <!-- Action buttons --> <div class="pdf-actions"> <button *ngIf="showDownloadButton" (click)="onDownload()">Download</button> <button *ngIf="showPrintButton" (click)="onPrint()">Print</button> </div> <!-- Search functionality --> <div class="pdf-search" *ngIf="showSearchBar"> <input type="text" placeholder="Search..." #searchInput> <button (click)="onSearch(searchInput.value)">Search</button> </div> </div> `, isInline: true, styles: [".pdf-controls{display:flex;padding:8px;border-bottom:1px solid #ddd;flex-wrap:wrap;gap:10px}.pdf-navigation,.pdf-zoom,.pdf-rotation,.pdf-actions,.pdf-search{display:flex;align-items:center;gap:5px}button{padding:4px 8px;background:#f0f0f0;border:1px solid #ccc;border-radius:3px;cursor:pointer}button:hover{background:#e0e0e0}button:disabled{opacity:.5;cursor:not-allowed}input[type=number],input[type=text]{width:50px;padding:4px;border:1px solid #ccc;border-radius:3px}input[type=text]{width:150px}select{padding:4px;border:1px solid #ccc;border-radius:3px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i2.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i2.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i2.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i2.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: PdfControlsComponent, decorators: [{ type: Component, args: [{ selector: 'ng-pdf-controls', standalone: true, imports: [CommonModule, FormsModule], template: ` <div class="pdf-controls"> <!-- Page navigation controls --> <div class="pdf-navigation" *ngIf="showNavigation"> <button (click)="onFirstPage()" [disabled]="currentPage <= 1">First</button> <button (click)="onPreviousPage()" [disabled]="currentPage <= 1">Previous</button> <span class="page-info"> <!-- Page input with two-way binding --> <input type="number" [ngModel]="currentPage" (ngModelChange)="onPageInputChange($event)" min="1" [max]="totalPages"> / {{ totalPages }} </span> <button (click)="onNextPage()" [disabled]="currentPage >= totalPages">Next</button> <button (click)="onLastPage()" [disabled]="currentPage >= totalPages">Last</button> </div> <!-- Zoom controls --> <div class="pdf-zoom" *ngIf="showZoomControls"> <button (click)="onZoomOut()">-</button> <span>{{ (zoom * 100).toFixed(0) }}%</span> <button (click)="onZoomIn()">+</button> <select [ngModel]="zoom" (ngModelChange)="onZoomSelect($event)"> <option [value]="0.5">50%</option> <option [value]="0.75">75%</option> <option [value]="1">100%</option> <option [value]="1.25">125%</option> <option [value]="1.5">150%</option> <option [value]="2">200%</option> </select> </div> <!-- Rotation controls --> <div class="pdf-rotation" *ngIf="showRotationControls"> <button (click)="onRotateLeft()">↺</button> <button (click)="onRotateRight()">↻</button> </div> <!-- Action buttons --> <div class="pdf-actions"> <button *ngIf="showDownloadButton" (click)="onDownload()">Download</button> <button *ngIf="showPrintButton" (click)="onPrint()">Print</button> </div> <!-- Search functionality --> <div class="pdf-search" *ngIf="showSearchBar"> <input type="text" placeholder="Search..." #searchInput> <button (click)="onSearch(searchInput.value)">Search</button> </div> </div> `, styles: [".pdf-controls{display:flex;padding:8px;border-bottom:1px solid #ddd;flex-wrap:wrap;gap:10px}.pdf-navigation,.pdf-zoom,.pdf-rotation,.pdf-actions,.pdf-search{display:flex;align-items:center;gap:5px}button{padding:4px 8px;background:#f0f0f0;border:1px solid #ccc;border-radius:3px;cursor:pointer}button:hover{background:#e0e0e0}button:disabled{opacity:.5;cursor:not-allowed}input[type=number],input[type=text]{width:50px;padding:4px;border:1px solid #ccc;border-radius:3px}input[type=text]{width:150px}select{padding:4px;border:1px solid #ccc;border-radius:3px}\n"] }] }], propDecorators: { currentPage: [{ type: Input }], totalPages: [{ type: Input }], zoom: [{ type: Input }], rotation: [{ type: Input }], showNavigation: [{ type: Input }], showZoomControls: [{ type: Input }], showRotationControls: [{ type: Input }], showDownloadButton: [{ type: Input }], showPrintButton: [{ type: Input }], showSearchBar: [{ type: Input }], showThumbnails: [{ type: Input }], showOutline: [{ type: Input }], pageChange: [{ type: Output }], zoomChange: [{ type: Output }], rotationChange: [{ type: Output }], download: [{ type: Output }], print: [{ type: Output }], search: [{ type: Output }], toggleThumbnails: [{ type: Output }], toggleOutline: [{ type: Output }] } }); // REMOVED: import 'pdfjs-dist/web/pdf_viewer.css'; - This is handled in the CSS file /** * Main component for rendering PDFs * Uses modern Angular patterns including standalone components and signals */ class PdfViewerComponent { constructor() { // Output events this.pageChange = new EventEmitter(); // Emitted when page changes this.documentLoaded = new EventEmitter(); // Emitted when document loads this.loadingStateChange = new EventEmitter(); // Emitted when loading state changes this.errorOccurred = new EventEmitter(); // Emitted when any error occurs // Legacy events (deprecated - use errorOccurred instead) this.documentLoadError = new EventEmitter(); // Emitted on load error this.errorStateChange = new EventEmitter(); // Emitted when error state changes // Service injection using modern inject function this.pdfService = inject(PdfService); // Subject for handling unsubscription on component destroy this.destroy$ = new Subject(); // Component state using signals (reactive primitive in modern Angular) this.currentPage = signal(1); // Current page number this.totalPages = signal(0); // Total pages in document this.zoom = signal(1); // Current zoom level this.rotation = signal(0); // Current rotation in degrees this.loading = signal(false); // Loading state this.error = signal(null); // Error message if any // Keep track of current render task to cancel if needed this.currentRenderTask = null; } /** * Set loading state and emit event */ setLoadingState(loading) { this.loading.set(loading); this.loadingStateChange.emit(loading); } /** * Set error state and emit event */ setErrorState(error) { this.error.set(error); this.errorStateChange.emit(error); } /** * Handle input changes - CRITICAL for src changes */ ngOnChanges(changes) { // Check if src has changed if (changes['src']) { const currentSrc = changes['src'].currentValue; const previousSrc = changes['src'].previousValue; // Only reload if src actually changed and is not the first change if (!changes['src'].firstChange && currentSrc !== previousSrc) { // console.log('PDF src changed, cleaning up and reloading...'); this.cleanupAndReload(); } } } /** * Initialize the component */ ngOnInit() { // Apply initial options if provided if (this.options?.initialZoom) { this.zoom.set(this.options.initialZoom); this.pdfService.setZoom(this.options.initialZoom); } else { // Default to 1.0 (100%) - let autoFit handle the scaling if needed this.zoom.set(1.0); this.pdfService.setZoom(1.0); } if (this.options?.initialPage) { this.currentPage.set(this.options.initialPage); this.pdfService.setCurrentPage(this.options.initialPage); } // Subscribe to service observables and update component state // The takeUntil operator automatically unsubscribes when destroy$ emits this.pdfService.currentPage$.pipe(takeUntil(this.destroy$)) .subscribe(page => { this.currentPage.set(page); this.pageChange.emit(page); }); this.pdfService.totalPages$.pipe(takeUntil(this.destroy$)) .subscribe(totalPages => { this.totalPages.set(totalPages); }); this.pdfService.zoom$.pipe(takeUntil(this.destroy$)) .subscribe(zoom => { this.zoom.set(zoom); // Re-render all pages when zoom changes if (this.pdfService.getCurrentDocument()) { this.renderAllPages(); } }); this.pdfService.rotation$.pipe(takeUntil(this.destroy$)) .subscribe(rotation => { this.rotation.set(rotation); // Re-render all pages when rotation changes if (this.pdfService.getCurrentDocument()) { this.renderAllPages(); } }); // Load the document this.loadDocument(); } /** * Perform complete cleanup of all PDF rendering artifacts */ async performCompleteCleanup() { // console.log('🧹 Performing REAL cleanup - not just covering up...'); // 1. Cancel any ongoing render tasks if (this.currentRenderTask) { try { await this.currentRenderTask.cancel(); // console.log('Cancelled render task'); } catch (e) { // console.log('Error cancelling render task:', e); } this.currentRenderTask = null; } // 2. ACTUALLY REMOVE ALL PDF ARTIFACTS - not just cover them const container = this.canvasContainer?.nativeElement; const pdfContentDiv = container?.parentElement; // Get the pdf-content div const pdfViewerDiv = pdfContentDiv?.parentElement; // Get the pdf-viewer div // console.log('Cleaning multiple container levels...'); if (container) { // console.log('Container contents before cleanup:', container.innerHTML.length); // Find and remove all PDF-related elements in ALL relevant containers const allContainers = [container, pdfContentDiv, pdfViewerDiv].filter(Boolean); let totalArtifacts = { pdfPages: 0, canvases: 0, textLayers: 0, annotationLayers: 0 }; allContainers.forEach((cont, index) => { if (cont) { const pdfPages = cont.querySelectorAll('.pdf-page'); const canvases = cont.querySelectorAll('canvas'); const textLayers = cont.querySelectorAll('.textLayer'); const annotationLayers = cont.querySelectorAll('.annotationLayer'); // console.log(`Level ${index} artifacts:`, { // pdfPages: pdfPages.length, // canvases: canvases.length, // textLayers: textLayers.length, // annotationLayers: annotationLayers.length // }); totalArtifacts.pdfPages += pdfPages.length; totalArtifacts.canvases += canvases.length; totalArtifacts.textLayers += textLayers.length; totalArtifacts.annotationLayers += annotationLayers.length; // Remove each type of element pdfPages.forEach(el => el.remove()); canvases.forEach(el => el.remove()); textLayers.forEach(el => el.remove()); annotationLayers.forEach(el => el.remove()); } }); // console.log('Total artifacts found and removed:', totalArtifacts); // Force clear the main container container.innerHTML = ''; // console.log('Container contents after cleanup:', container.innerHTML.length); // Reset container to clean state container.className = ''; container.style.cssText = ''; } // 3. Reset component state AND force controls to update this.totalPages.set(0); this.currentPage.set(1); this.zoom.set(1); this.rotation.set(0); // 4. Clear service state this.pdfService.clearDocument(); // 5. Force component template to re-render by clearing error/loading states this.loading.set(false); this.error.set(null); // 5. Force Angular change detection await new Promise(resolve => setTimeout(resolve, 100)); // console.log('✅ REAL cleanup finished - no artifacts should remain'); } /** * Clean up and reload PDF when src changes */ async cleanupAndReload() { // console.log('Starting REAL cleanup and reload process...'); // 1. ACTUALLY REMOVE all PDF content from ALL levels const container = this.canvasContainer?.nativeElement; const pdfContentDiv = container?.parentElement; const pdfViewerDiv = pdfContentDiv?.parentElement; // console.log('Multi-level cleanup before reload...'); if (container) { // console.log('Container before cleanup:', container.innerHTML.length); // Remove all PDF-specific elements from all container levels const allContainers = [container, pdfContentDiv, pdfViewerDiv].filter(Boolean); allContainers.forEach((cont, index) => { if (cont) { const pdfPages = cont.querySelectorAll('.pdf-page'); const canvases = cont.querySelectorAll('canvas'); const textLayers = cont.querySelectorAll('.textLayer'); const annotationLayers = cont.querySelectorAll('.annotationLayer'); // console.log(`Cleanup level ${index}:`, { // pdfPages: pdfPages.length, // canvases: canvases.length, // textLayers: textLayers.length, // annotationLayers: annotationLayers.length // }); pdfPages.forEach(el => el.remove()); canvases.forEach(el => el.remove()); textLayers.forEach(el => el.remove()); annotationLayers.forEach(el => el.remove()); } }); // Force clear everything container.innerHTML = ''; container.className = ''; container.style.cssText = ''; // console.log('Container after cleanup:', container.innerHTML.length); } // 2. Cancel any pending render tasks if (this.currentRenderTask) { try { await this.currentRenderTask.cancel(); // console.log('Cancelled previous render task'); } catch (e) { // console.log('Error cancelling render task:', e); } this.currentRenderTask = null; } // 3. Reset component state this.setLoadingState(true); this.setErrorState(null); // 4. Clear service state this.pdfService.clearDocument(); // 5. Small delay to ensure cleanup is complete await new Promise(resolve => setTimeout(resolve, 50)); // 6. Load new document await this.loadDocument(); // console.log('REAL cleanup and reload completed'); } /** * Clean up subscriptions on component destruction */ ngOnDestroy() { // Cancel any pending render tasks if (this.currentRenderTask) { try { this.currentRenderTask.cancel(); } catch (e) { //console.log('Error cancelling render task during destroy:', e); } } // Complete the destroy subject to unsubscribe from all observables this.destroy$.next(); this.destroy$.complete(); } /** * Load the PDF document */ async loadDocument() { if (!this.src) { this.error.set('No PDF source provided'); return; } // Ensure container is clear before starting const container = this.canvasContainer?.nativeElement; if (container) { container.innerHTML = ''; } this.loading.set(true); this.error.set(null); // console.log(`Loading PDF from source: ${typeof this.src === 'string' ? this.src : 'Binary data'}`); try { // Use service to load the document const pdfDocument = await this.pdfService.loadDocument(this.src); // console.log('PDF document loaded successfully!', pdfDocument); this.documentLoaded.emit(pdfDocument); // Set total pages ONLY on successful load this.totalPages.set(pdfDocument.numPages); // console.log(`Total pages: ${pdfDocument.numPages}`); // Set current page to 1 or initialPage ONLY on successful load const initialPage = this.options?.initialPage || 1; this.currentPage.set(initialPage); this.pdfService.setCurrentPage(initialPage); // Small delay to ensure DOM is ready await new Promise(resolve => setTimeout(resolve, 10)); // Render all pages in continuous mode await this.renderAllPages(); } catch (err) { // console.error('Error loading PDF:', err); // Categorize and handle the error const errorType = this.categorizeError(err); this.handlePdfError(errorType, err, { action: 'loadDocument' }); // CRITICAL: Complete cleanup on load failure await this.performCompleteCleanup(); } finally { this.loading.set(false); } } /** * Render a specific page of the PDF * @param pageNumber The page number to render */ async renderPage(pageNumber) { // Ensure there's a page number if (!pageNumber) { pageNumber = 1; } //console.log(`Attempting to render page ${pageNumber}`); // Cancel any ongoing render task if (this.currentRenderTask) { //console.log('Cancelling previous render task'); try { await this.currentRenderTask.cancel(); } catch (e) { //console.log('Error cancelling previous render task:', e); } this.currentRenderTask = null; } try { // Get the document directly from the service const pdfDocument = this.pdfService.getCurrentDocument(); if (!pdfDocument) { //console.error('No PDF document available'); return; } //console.log(`PDF document has ${pdfDocument.numPages} pages`); // Get the page from the document const page = await pdfDocument.getPage(pageNumber); //console.log('Page object retrieved:', page !== null); // Calculate scale to fit the canvas const scale = this.zoom(); // Set up viewport based on current zoom and rotation const viewport = page.getViewport({ scale: scale, rotation: this.rotation() }); // Clear the canvas container const container = this.canvasContainer?.nativeElement; if (!container) { // console.error('Canvas container not available'); return; } container.innerHTML = ''; // Create a new canvas element for this render operation const canvas = document.createElement('canvas'); // Apply device pixel ratio for sharper rendering on high-DPI displays const pixelRatio = window.devicePixelRatio || 1; // Scale canvas by pixel ratio for sharper rendering const scaledWidth = Math.floor(viewport.width * pixelRatio); const scaledHeight = Math.floor(viewport.height * pixelRatio); // Set canvas dimensions with pixel ratio factored in canvas.width = scaledWidth; canvas.height = scaledHeight; // Set display size through CSS (original size) canvas.style.width = Math.floor(viewport.width) + 'px'; canvas.style.height = Math.floor(viewport.height) + 'px'; container.appendChild(canvas); // Get the canvas context const context = canvas.getContext('2d'); if (!context) { //console.error('Canvas rendering context not available'); this.error.set('Canvas rendering context not available'); return; } // Scale the context to account for the device pixel ratio context.scale(pixelRatio, pixelRatio); //console.log(`Rendering with viewport: ${viewport.width}x${viewport.height}, scale: ${scale}, pixel ratio: ${pixelRatio}`); // Render the page to the canvas const renderContext = { canvasContext: context, viewport: viewport }; //console.log('Starting page rendering...'); // Store the render task for potential cancellation this.currentRenderTask = page.render(renderContext); // Wait for rendering to complete await this.currentRenderTask.promise; //console.log('Page rendered successfully'); this.currentRenderTask = null; } catch (err) { // Check if this is a cancellation error, which is expected when navigating quickly if (err && err.name === 'RenderingCancelledException') { //console.log('Rendering was cancelled'); } else { //console.error('Error rendering page:', err); const errorType = this.categorizeError(err); this.handlePdfError(errorType, err, { pageNumber, action: 'renderPage' }); } } } /** * Render all pages of the PDF in continuous mode */ async renderAllPages() { // Get the document directly from the service const pdfDocument = this.pdfService.getCurrentDocument(); if (!pdfDocument) { //console.error('No PDF document available'); return; } // Auto-scale to fit the container width if needed try { if (this.options?.autoFit !== false) { const container = this.canvasContainer.nativeElement; //console.log('Container width:', container.clientWidth); const containerWidth = container.clientWidth || 800; // Fallback to 800 if clientWidth is 0 const firstPage = await pdfDocument.getPage(1); const viewport = firstPage.getViewport({ scale: 1.0 }); const pageWidth = viewport.width; //console.log('Page width at scale 1.0:', pageWidth); //console.log('Container width:', containerWidth); // Calculate scale to fit container width (with some margin) - FIXED: No minimum zoom restriction const scaleFactor = (containerWidth - 40) / pageWidth; // Apply reasonable bounds to prevent extreme scaling const boundedScale = Math.max(0.1, Math.min(scaleFactor, 3.0)); //console.log('Calculated scale factor:', boundedScale); // Only update if significantly