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
JavaScript
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