/** * Created by vadimdez on 21/06/16. */ import { Component, Input, Output, ElementRef, EventEmitter, OnChanges, SimpleChanges, OnInit, OnDestroy, ViewChild, AfterViewChecked, NgZone } from '@angular/core'; import { from, fromEvent, Subject } from 'rxjs'; import { debounceTime, filter, takeUntil } from 'rxjs/operators'; import * as PDFJS from 'pdfjs-dist/legacy/build/pdf'; import * as PDFJSViewer from 'pdfjs-dist/legacy/web/pdf_viewer'; import { createEventBus } from '../utils/event-bus-utils'; import { assign, isSSR } from '../utils/helpers'; import type { PDFSource, PDFPageProxy, PDFProgressData, PDFDocumentProxy, PDFDocumentLoadingTask, PDFViewerOptions } from './typings'; if (!isSSR()) { assign(PDFJS, 'verbosity', PDFJS.VerbosityLevel.INFOS); } export enum RenderTextMode { DISABLED, ENABLED, ENHANCED } @Component({ selector: 'pdf-viewer', template: `
`, styleUrls: ['./pdf-viewer.component.scss'] }) export class PdfViewerComponent implements OnChanges, OnInit, OnDestroy, AfterViewChecked { static CSS_UNITS = 96.0 / 72.0; static BORDER_WIDTH = 9; @ViewChild('pdfViewerContainer') pdfViewerContainer; private isVisible = false; private pdfMultiPageViewer: any; private pdfMultiPageLinkService: any; private pdfMultiPageFindController: any; private pdfSinglePageViewer: any; private pdfSinglePageLinkService: any; private pdfSinglePageFindController: any; private _cMapsUrl = typeof PDFJS !== 'undefined' ? `https://unpkg.com/pdfjs-dist@${(PDFJS as any).version}/cmaps/` : null; private _renderText = true; private _renderTextMode: RenderTextMode = RenderTextMode.ENABLED; private _stickToPage = false; private _originalSize = true; private _pdf: PDFDocumentProxy; private _page = 1; private _zoom = 1; private _zoomScale: 'page-height' | 'page-fit' | 'page-width' = 'page-width'; private _rotation = 0; private _showAll = true; private _canAutoResize = true; private _fitToPage = false; private _externalLinkTarget = 'blank'; private _showBorders = false; private lastLoaded: string | Uint8Array | PDFSource; private _latestScrolledPage: number; private resizeTimeout: number | null = null; private pageScrollTimeout: number | null = null; private isInitialized = false; private loadingTask: PDFDocumentLoadingTask; private destroy$ = new Subject(); @Output('after-load-complete') afterLoadComplete = new EventEmitter(); @Output('page-rendered') pageRendered = new EventEmitter(); @Output('pages-initialized') pageInitialized = new EventEmitter(); @Output('text-layer-rendered') textLayerRendered = new EventEmitter(); @Output('error') onError = new EventEmitter(); @Output('on-progress') onProgress = new EventEmitter(); @Output() pageChange: EventEmitter = new EventEmitter(true); @Input() src: string | Uint8Array | PDFSource; @Input('c-maps-url') set cMapsUrl(cMapsUrl: string) { this._cMapsUrl = cMapsUrl; } @Input('page') set page(_page) { _page = parseInt(_page, 10) || 1; const originalPage = _page; if (this._pdf) { _page = this.getValidPageNumber(_page); } this._page = _page; if (originalPage !== _page) { this.pageChange.emit(_page); } } @Input('render-text') set renderText(renderText: boolean) { this._renderText = renderText; } @Input('render-text-mode') set renderTextMode(renderTextMode: RenderTextMode) { this._renderTextMode = renderTextMode; } @Input('original-size') set originalSize(originalSize: boolean) { this._originalSize = originalSize; } @Input('show-all') set showAll(value: boolean) { this._showAll = value; } @Input('stick-to-page') set stickToPage(value: boolean) { this._stickToPage = value; } @Input('zoom') set zoom(value: number) { if (value <= 0) { return; } this._zoom = value; } get zoom() { return this._zoom; } @Input('zoom-scale') set zoomScale(value: 'page-height' | 'page-fit' | 'page-width') { this._zoomScale = value; } get zoomScale() { return this._zoomScale; } @Input('rotation') set rotation(value: number) { if (!(typeof value === 'number' && value % 90 === 0)) { console.warn('Invalid pages rotation angle.'); return; } this._rotation = value; } @Input('external-link-target') set externalLinkTarget(value: string) { this._externalLinkTarget = value; } @Input('autoresize') set autoresize(value: boolean) { this._canAutoResize = Boolean(value); } @Input('fit-to-page') set fitToPage(value: boolean) { this._fitToPage = Boolean(value); } @Input('show-borders') set showBorders(value: boolean) { this._showBorders = Boolean(value); } static getLinkTarget(type: string) { switch (type) { case 'blank': return (PDFJSViewer as any).LinkTarget.BLANK; case 'none': return (PDFJSViewer as any).LinkTarget.NONE; case 'self': return (PDFJSViewer as any).LinkTarget.SELF; case 'parent': return (PDFJSViewer as any).LinkTarget.PARENT; case 'top': return (PDFJSViewer as any).LinkTarget.TOP; } return null; } constructor(private element: ElementRef, private ngZone: NgZone) { if (isSSR()) { return; } let pdfWorkerSrc: string; if ( window.hasOwnProperty('pdfWorkerSrc') && typeof (window as any).pdfWorkerSrc === 'string' && (window as any).pdfWorkerSrc ) { pdfWorkerSrc = (window as any).pdfWorkerSrc; } else { pdfWorkerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${(PDFJS as any).version }/legacy/build/pdf.worker.js`; } assign(PDFJS.GlobalWorkerOptions, 'workerSrc', pdfWorkerSrc); } ngAfterViewChecked(): void { if (this.isInitialized) { return; } const offset = this.pdfViewerContainer.nativeElement.offsetParent; if (this.isVisible === true && offset == null) { this.isVisible = false; return; } if (this.isVisible === false && offset != null) { this.isVisible = true; setTimeout(() => { this.initialize(); this.ngOnChanges({ src: this.src } as any); }); } } ngOnInit() { this.initialize(); this.setupResizeListener(); } ngOnDestroy() { this.clear(); this.destroy$.next(); this.loadingTask = null; } get pdfLinkService(): any { return this._showAll ? this.pdfMultiPageLinkService : this.pdfSinglePageLinkService; } get pdfViewer(): any { return this.getCurrentViewer(); } get pdfFindController(): any { return this._showAll ? this.pdfMultiPageFindController : this.pdfSinglePageFindController; } ngOnChanges(changes: SimpleChanges) { if (isSSR() || !this.isVisible) { return; } if ('src' in changes) { this.loadPDF(); } else if (this._pdf) { if ('renderText' in changes) { this.getCurrentViewer().textLayerMode = this._renderText ? this._renderTextMode : RenderTextMode.DISABLED; this.resetPdfDocument(); } else if ('showAll' in changes) { this.resetPdfDocument(); } if ('page' in changes) { const { page } = changes; if (page.currentValue === this._latestScrolledPage) { return; } // New form of page changing: The viewer will now jump to the specified page when it is changed. // This behavior is introduced by using the PDFSinglePageViewer this.getCurrentViewer().scrollPageIntoView({ pageNumber: this._page }); } this.update(); } } public updateSize() { const currentViewer = this.getCurrentViewer(); from( (this._pdf.getPage( currentViewer.currentPageNumber ) as unknown) as Promise ) .pipe(takeUntil(this.destroy$)) .subscribe({ next: (page: PDFPageProxy) => { const rotation = this._rotation || page.rotate; const viewportWidth = (page as any).getViewport({ scale: this._zoom, rotation }).width * PdfViewerComponent.CSS_UNITS; let scale = this._zoom; let stickToPage = true; // Scale the document when it shouldn't be in original size or doesn't fit into the viewport if ( !this._originalSize || (this._fitToPage && viewportWidth > this.pdfViewerContainer.nativeElement.clientWidth) ) { const viewPort = (page as any).getViewport({ scale: 1, rotation }); scale = this.getScale(viewPort.width, viewPort.height); stickToPage = !this._stickToPage; } currentViewer._setScale(scale, stickToPage); } }); } public clear() { if (this.loadingTask && !this.loadingTask.destroyed) { this.loadingTask.destroy(); } if (this._pdf) { this._pdf.destroy(); this._pdf = null; this.pdfMultiPageViewer.setDocument(null); this.pdfSinglePageViewer.setDocument(null); this.pdfMultiPageLinkService.setDocument(null, null); this.pdfSinglePageLinkService.setDocument(null, null); this.pdfMultiPageFindController.setDocument(null); this.pdfSinglePageFindController.setDocument(null); } } private getPDFLinkServiceConfig() { const pdfLinkServiceConfig: any = {}; const linkTarget = PdfViewerComponent.getLinkTarget(this._externalLinkTarget); if (linkTarget) { pdfLinkServiceConfig.externalLinkTarget = linkTarget; } return pdfLinkServiceConfig; } private setupMultiPageViewer() { assign(PDFJS, 'disableTextLayer', !this._renderText); const eventBus = createEventBus(PDFJSViewer, this.destroy$); fromEvent(eventBus, 'pagerendered') .pipe(takeUntil(this.destroy$)) .subscribe((event) => { this.pageRendered.emit(event); }); fromEvent(eventBus, 'pagesinit') .pipe(takeUntil(this.destroy$)) .subscribe((event) => { this.pageInitialized.emit(event); }); fromEvent(eventBus, 'pagechanging') .pipe(takeUntil(this.destroy$)) .subscribe(({ pageNumber }) => { if (this.pageScrollTimeout) { clearTimeout(this.pageScrollTimeout); } this.pageScrollTimeout = window.setTimeout(() => { this._latestScrolledPage = pageNumber; this.pageChange.emit(pageNumber); }, 100); }); fromEvent(eventBus, 'textlayerrendered') .pipe(takeUntil(this.destroy$)) .subscribe((event) => { this.textLayerRendered.emit(event); }); this.pdfMultiPageLinkService = new PDFJSViewer.PDFLinkService({ eventBus, ...this.getPDFLinkServiceConfig() }); this.pdfMultiPageFindController = new PDFJSViewer.PDFFindController({ linkService: this.pdfMultiPageLinkService, eventBus }); const pdfOptions: PDFViewerOptions = { eventBus, container: this.element.nativeElement.querySelector('div'), removePageBorders: !this._showBorders, linkService: this.pdfMultiPageLinkService, textLayerMode: this._renderText ? this._renderTextMode : RenderTextMode.DISABLED, findController: this.pdfMultiPageFindController, renderer: 'canvas', l10n: undefined }; this.pdfMultiPageViewer = new PDFJSViewer.PDFViewer(pdfOptions); this.pdfMultiPageLinkService.setViewer(this.pdfMultiPageViewer); this.pdfMultiPageFindController.setDocument(this._pdf); } private setupSinglePageViewer() { assign(PDFJS, 'disableTextLayer', !this._renderText); const eventBus = createEventBus(PDFJSViewer, this.destroy$); fromEvent(eventBus, 'pagechanging') .pipe(takeUntil(this.destroy$)) .subscribe(({ pageNumber }) => { if (pageNumber !== this._page) { this.page = pageNumber; } }); fromEvent(eventBus, 'pagerendered') .pipe(takeUntil(this.destroy$)) .subscribe((event) => { this.pageRendered.emit(event); }); fromEvent(eventBus, 'pagesinit') .pipe(takeUntil(this.destroy$)) .subscribe((event) => { this.pageInitialized.emit(event); }); fromEvent(eventBus, 'textlayerrendered') .pipe(takeUntil(this.destroy$)) .subscribe((event) => { this.textLayerRendered.emit(event); }); this.pdfSinglePageLinkService = new PDFJSViewer.PDFLinkService({ eventBus, ...this.getPDFLinkServiceConfig() }); this.pdfSinglePageFindController = new PDFJSViewer.PDFFindController({ linkService: this.pdfSinglePageLinkService, eventBus }); const pdfOptions = { eventBus, container: this.element.nativeElement.querySelector('div'), removePageBorders: !this._showBorders, linkService: this.pdfSinglePageLinkService, textLayerMode: this._renderText ? this._renderTextMode : RenderTextMode.DISABLED, findController: this.pdfSinglePageFindController, renderer: 'canvas', l10n: undefined, }; this.pdfSinglePageViewer = new PDFJSViewer.PDFSinglePageViewer(pdfOptions); this.pdfSinglePageLinkService.setViewer(this.pdfSinglePageViewer); this.pdfSinglePageFindController.setDocument(this._pdf); this.pdfSinglePageViewer._currentPageNumber = this._page; } private getValidPageNumber(page: number): number { if (page < 1) { return 1; } if (page > this._pdf.numPages) { return this._pdf.numPages; } return page; } private getDocumentParams() { const srcType = typeof this.src; if (!this._cMapsUrl) { return this.src; } const params: any = { cMapUrl: this._cMapsUrl, cMapPacked: true }; if (srcType === 'string') { params.url = this.src; } else if (srcType === 'object') { if ((this.src as any).byteLength !== undefined) { params.data = this.src; } else { Object.assign(params, this.src); } } return params; } private loadPDF() { if (!this.src) { return; } if (this.lastLoaded === this.src) { this.update(); return; } this.clear(); this.loadingTask = PDFJS.getDocument(this.getDocumentParams()); this.loadingTask.onProgress = (progressData: PDFProgressData) => { this.onProgress.emit(progressData); }; const src = this.src; from(this.loadingTask.promise as Promise) .pipe(takeUntil(this.destroy$)) .subscribe({ next: (pdf) => { this._pdf = pdf; this.lastLoaded = src; this.afterLoadComplete.emit(pdf); if (!this.pdfMultiPageViewer) { this.setupMultiPageViewer(); this.setupSinglePageViewer(); } this.resetPdfDocument(); this.update(); }, error: (error) => { this.lastLoaded = null; this.onError.emit(error); } }); } private update() { this.page = this._page; this.render(); } private render() { this._page = this.getValidPageNumber(this._page); const currentViewer = this.getCurrentViewer(); if ( this._rotation !== 0 || currentViewer.pagesRotation !== this._rotation ) { setTimeout(() => { currentViewer.pagesRotation = this._rotation; }); } if (this._stickToPage) { setTimeout(() => { currentViewer.currentPageNumber = this._page; }); } this.updateSize(); } private getScale(viewportWidth: number, viewportHeight: number) { const borderSize = (this._showBorders ? 2 * PdfViewerComponent.BORDER_WIDTH : 0); const pdfContainerWidth = this.pdfViewerContainer.nativeElement.clientWidth - borderSize; const pdfContainerHeight = this.pdfViewerContainer.nativeElement.clientHeight - borderSize; if (pdfContainerHeight === 0 || viewportHeight === 0 || pdfContainerWidth === 0 || viewportWidth === 0) { return 1; } let ratio = 1; switch (this._zoomScale) { case 'page-fit': ratio = Math.min((pdfContainerHeight / viewportHeight), (pdfContainerWidth / viewportWidth)); break; case 'page-height': ratio = (pdfContainerHeight / viewportHeight); break; case 'page-width': default: ratio = (pdfContainerWidth / viewportWidth); break; } return (this._zoom * ratio) / PdfViewerComponent.CSS_UNITS; } private getCurrentViewer(): any { return this._showAll ? this.pdfMultiPageViewer : this.pdfSinglePageViewer; } private resetPdfDocument() { this.pdfFindController.setDocument(this._pdf); if (this._showAll) { this.pdfSinglePageViewer.setDocument(null); this.pdfSinglePageLinkService.setDocument(null); this.pdfMultiPageViewer.setDocument(this._pdf); this.pdfMultiPageLinkService.setDocument(this._pdf, null); } else { this.pdfMultiPageViewer.setDocument(null); this.pdfMultiPageLinkService.setDocument(null); this.pdfSinglePageViewer.setDocument(this._pdf); this.pdfSinglePageLinkService.setDocument(this._pdf, null); } } private initialize(): void { if (isSSR() || !this.isVisible) { return; } this.isInitialized = true; this.setupMultiPageViewer(); this.setupSinglePageViewer(); } private setupResizeListener(): void { if (isSSR()) { return; } this.ngZone.runOutsideAngular(() => { fromEvent(window, 'resize') .pipe( debounceTime(100), filter(() => this._canAutoResize && !!this._pdf), takeUntil(this.destroy$) ) .subscribe(() => { this.updateSize(); }); }); } }