///
import {
EVENTS,
PVEventHandler,
PVHighlightClickEvent,
PVLoadEvent,
PVPageChangeEvent,
PVPageResizeEvent, PVScrollEvent
} from "./event";
import {getViewSize, PDFPage} from "./page";
import {getEventPath} from "./dom";
import {animate, Animation} from "./animtion";
import {extendObject, isDef, isUndef} from "./utils";
import {Log, LOG_LEVEL} from "./log";
import "./style.scss";
const pkg = require('../package.json');
export const pdfjs = require('pdfjs-dist/build/pdf.js');
// @ts-ignore
if (IS_DEV) {
const PdfjsWorker = require('worker-loader!pdfjs-dist/build/pdf.worker.js');
pdfjs.GlobalWorkerOptions.workerPort = new PdfjsWorker();
}
const PAGE_GAP = 10;
const DPR = window.devicePixelRatio || 1;
const CLASS_NAME = 'pdf-viewer-666';
type Options = {
container?: HTMLElement
url?: string
data?: [Uint8Array]
file?: File
cmaps?: string
gap?: number
isRenderText?: boolean
logTitle?: string
containerBackground?: string
borderStyle?: string
debug?: boolean,
pdfjsParams?: {
[key: string]: any
}
}
interface HighlightParams {
page: number
x: number
y: number
w: number
h: number
highlightClass?: string
attrs?: {
[key: string]: string
}
}
export class PDFViewer {
static version = pkg.version;
private elem: HTMLDivElement | null = null;
private isRenderText: boolean = false;
private pdfTask: PDFDocumentLoadingTask | null = null;
private dc: PDFDocumentProxy | null = null;
private pages: Array = [];
private width: number;
private firstPageOriginWidth: number = 0;
private firstPageOriginHeight: number = 0;
private currentPage: number = 0;
private totalPages: number = 0;
private eventHandler: PVEventHandler;
private renderTimer: number | null = null;
private scrollAnimation: Animation | null = null;
private ready: boolean = false;
// 在container中插入一个辅助元素,用来正确获取页面的宽度(因为滚动条的原因)(离线无效)
private pageHelper: HTMLElement | null = document.createElement('div');
private readonly log: Log;
private readonly debug: boolean;
private destroyed = false;
constructor(options: Options) {
this.isRenderText = isDef(options.isRenderText) ? !!options.isRenderText : false;
this.width = options.container ? options.container.clientWidth : 0;
const offline = isUndef(options.container);
this.debug = isDef(options.debug) ? !!options.debug : false;
this.log = new Log(options.logTitle || '', this.debug ? LOG_LEVEL.INFO : LOG_LEVEL.WARN, this.debug);
this.eventHandler = new PVEventHandler(this.log);
if (!offline) {
this.elem = document.createElement('div');
this.elem.className = CLASS_NAME;
this.elem.style.background = isDef(options.containerBackground) ? options.containerBackground as string : '#808080';
this.elem.style.border = isDef(options.borderStyle) ? options.borderStyle as string : 'none';
this.elem.style.overflow = 'auto';
this.elem.style.padding = `0 ${isDef(options.gap) ? options.gap : 10}px`;
this.elem.appendChild(this.pageHelper as HTMLElement);
this.elem.addEventListener('scroll', onscroll);
this.elem.addEventListener('click', onclick);
(options.container as HTMLElement).appendChild(this.elem);
// @ts-ignore
this.elem['pv'] = this;
}
let cfg: DocumentInitParameters = {
cMapUrl: options.cmaps,
cMapPacked: true,
};
if ('pdfjsParams' in options) {
extendObject(cfg, options.pdfjsParams || {});
}
if (isDef(options.url)) {
cfg['url'] = options.url;
this._getDocument(cfg);
} else if (isDef(options.data)) {
cfg['data'] = options.data;
this._getDocument(cfg);
} else if (isDef(options.file)) {
if (!(options.file instanceof File)) {
this.log.error('Invalid param "file"');
}
const fr = new FileReader();
fr.readAsArrayBuffer(options.file as File);
fr.onload = () => {
// @ts-ignore
cfg['data'] = new Uint8Array(fr.result);
this._getDocument(cfg);
};
fr.onerror = () => {
this.log.error('The param "file" cannot be loaded');
}
} else {
this.log.error('You must specify the pdf source by "url", "data" or "file"');
}
}
private _handlePageResize(ps: { [pageNum: number]: { w: number, h: number } }) {
this.eventHandler.trigger(EVENTS.PAGE_RESIZE, new PVPageResizeEvent(this, ps));
}
private _render(force: boolean = false) {
if (!this.ready || !this.elem || this.pages.length === 0) {
return;
}
const vh = this.elem.clientHeight;
const scrollTop = this.elem.scrollTop;
let currentPage = 0;
let currentPageVisibleHeight = 0;
let prevPageHeight = 0;
this.pages.forEach((page, pageIdx) => {
const pageElem = page.getPageElement();
const pageHeight = (pageElem as HTMLElement).clientHeight;
const tmpPageTop = scrollTop - prevPageHeight;
const tmpGap = pageIdx === 0 ? 0 : PAGE_GAP;// 加上页面的margin-top,这个margin很少变动,所以写死
if (tmpPageTop < vh + 2 * pageHeight && tmpPageTop > -vh - 2 * pageHeight) {
// 页面顶部在可视上下浮动5个页面高度的范围内都渲染
page.render((this.dc as unknown) as PDFDocumentProxy, force);
// 哪个页面在container中占的空间大就使用哪个页面作为当前页面,如果相同,页码小优先
let tmpCurrentPage = 0;
let tmpCurrentPageVisibleHeight = 0;
if (prevPageHeight >= scrollTop && prevPageHeight < scrollTop + vh) {
tmpCurrentPage = pageIdx + 1;
tmpCurrentPageVisibleHeight = pageHeight + tmpGap - Math.max(prevPageHeight + pageHeight - scrollTop - vh, 0);
} else if (prevPageHeight + pageHeight + tmpGap > scrollTop && prevPageHeight + pageHeight + tmpGap <= scrollTop + vh) {
tmpCurrentPage = pageIdx + 1;
tmpCurrentPageVisibleHeight = pageHeight + tmpGap - Math.max(scrollTop - prevPageHeight, 0);
} else if (prevPageHeight <= scrollTop && prevPageHeight + pageHeight + tmpGap >= scrollTop + vh) {
tmpCurrentPage = pageIdx + 1;
tmpCurrentPageVisibleHeight = vh;
}
if (tmpCurrentPage > 0 && tmpCurrentPageVisibleHeight > currentPageVisibleHeight) {
currentPage = tmpCurrentPage;
currentPageVisibleHeight = tmpCurrentPageVisibleHeight;
}
} else {
page.revoke();
}
prevPageHeight += pageHeight + tmpGap;
});
if (currentPage !== this.currentPage) {
this.currentPage = currentPage;
this.eventHandler.trigger(EVENTS.PAGE_CHANGE, new PVPageChangeEvent(this, this.currentPage, this.totalPages));
}
}
private _getDocument(cfg: DocumentInitParameters) {
this.pdfTask = pdfjs.getDocument(cfg);
this.log.mark('getdoc');
(this.pdfTask as PDFDocumentLoadingTask).promise
.then((dc) => {
this.log.measure('getdoc', 'get document');
this.log.removeMark('getdoc');
this.dc = dc;
const numPages = this.dc.numPages;
this.totalPages = numPages;
if (numPages === 0) {
this.ready = true;
return;
}
if (!this.elem) {
this.eventHandler.trigger(EVENTS.LOAD, new PVLoadEvent(this));
return;
}
/* choose the first page size as initial size for all pages */
this.dc.getPage(1)
.then((page) => {
const vp = page.getViewport({});
this.firstPageOriginWidth = vp.viewBox[2];
this.firstPageOriginHeight = vp.viewBox[3];
for (let i = 1; i <= numPages; i++) {
const p = new PDFPage({
pdfjs,
pageNum: i,
pdfPage: i === 1 ? page : null,
width: this.width,
height: this.width * this.firstPageOriginHeight / this.firstPageOriginWidth,
isRenderText: this.isRenderText,
pageResizeCallback: this._handlePageResize.bind(this),
log: this.log
});
this.pages.push(p);
(this.elem as HTMLElement).appendChild(p.getPageElement() as HTMLElement);
}
this.ready = true;
})
.then(() => {
// 再次调用then方法是为了延迟渲染的执行,以便上面的页面元素都插入到container中,并且样式得到正确应用
// 因为可能产生滚动条,所以重置页面尺寸
this.width = (this.pageHelper as HTMLElement).clientWidth;
const height = this.width / this.firstPageOriginWidth * this.firstPageOriginHeight;
const pageSizes: {
[key: number]: {
w: number,
h: number
}
} = {};
this.pages.forEach((p, pi) => {
p.resize(this.width);
pageSizes[pi + 1] = {
w: this.width,
h: height
};
});
Promise.resolve()
.then(() => {
this.eventHandler.trigger(EVENTS.LOAD, new PVLoadEvent(this));
})
.then(() => {
this.eventHandler.trigger(EVENTS.PAGE_RESIZE, new PVPageResizeEvent(this, pageSizes));
})
.then(() => {
this._render();
});
})
.catch((err) => {
this.log.error('Render first page error', err);
});
})
.catch((err) => {
this.log.error('get document fail.', err);
});
}
addEventListener(name: string, handler: Function): PDFViewer {
this.eventHandler.addHandler(name, handler);
return this;
}
removeEventListener(eventName: string, handler: Function): PDFViewer {
this.eventHandler.removeHandler(eventName, handler);
return this;
}
getPDFInfo() {
return {
// 总页数
totalPages: this.totalPages,
// 页面间距
pageGap: PAGE_GAP
};
}
scrollTo(page: number, pageTop: number, cb: Function = () => {
}): PDFViewer {
if (!this.ready || !this.elem) {
return this;
}
if (page < 1 || page > this.pages.length) {
return this;
}
if (this.scrollAnimation) {
this.scrollAnimation.stop();
this.scrollAnimation = null;
}
const pageInstance = this.pages[page - 1];
const pageElement = pageInstance.getPageElement();
const scrollTop = (pageElement as HTMLElement).offsetTop + pageInstance.calcSize(pageTop) - this.elem.offsetHeight / 2;
this.scrollAnimation = animate(this.elem.scrollTop, scrollTop, 300, (v) => {
(this.elem as HTMLElement).scrollTop = v;
}, cb);
return this;
}
highlight(params: HighlightParams): symbol | null {
if (!this.ready) {
return null;
}
if (params.page < 1 || params.page > this.pages.length) {
return null;
}
return this.pages[params.page - 1].highlight(params.x, params.y, params.w, params.h, params.highlightClass ?? 'pdf-highlight', params.attrs ?? {});
}
removeHighlight(page: number, id: symbol): PDFViewer {
if (!this.elem) {
return this;
}
if (page >= 1) {
if (page > this.pages.length) {
return this;
}
if (id !== undefined) {
this.pages[page - 1].removeHighlight(id);
} else {
this.pages[page - 1].removeAllHighlights();
}
} else {
this.pages.forEach((p) => {
p.removeAllHighlights();
});
}
return this;
}
highlightFocus(page: number, id: symbol, highlightFocusClass = 'pdf-highlight-focus'): PDFViewer {
if (!this.ready || !this.elem) {
return this;
}
if (page >= 1 && page <= this.pages.length) {
this.pages[page - 1].highlightFocus(id, highlightFocusClass);
}
return this;
}
highlightBlur(page: number, id: symbol): PDFViewer {
if (!this.ready || !this.elem) {
return this;
}
if (page >= 1 && page <= this.pages.length) {
this.pages[page - 1].highlightBlur(id);
}
return this;
}
renderPage(page: number, width: number, cb: (canvas: HTMLCanvasElement) => void, devicePixelCompatible = true) {
(this.pdfTask as PDFDocumentLoadingTask).promise
.then((dc) => {
dc.getPage(page).then((pdfPage: PDFPageProxy) => {
width = (width || this.width) * (devicePixelCompatible ? DPR : 1);
const viewport = pdfPage.getViewport();
const vs = getViewSize(viewport);
const scale = width / vs.w;
const height = scale * vs.h;
const vp = pdfPage.getViewport({scale,});
/* render canvas layer */
const canvas = document.createElement('canvas');
const canvasCtx = canvas.getContext('2d');
canvas.width = width;
canvas.height = height;
const canvasRenderTask = pdfPage.render({
canvasContext: canvasCtx,
viewport: vp,
});
canvasRenderTask.promise
.then(() => {
cb(canvas);
})
.catch((err) => {
this.log.error('Render offline page canvas fail.', `page: ${page}, width: ${width}`, err);
});
});
})
.catch((err) => {
this.log.error('Render offline page fail.', `page: ${page}, width: ${width}`, err);
});
}
resize(width?: number): PDFViewer {
if (this.firstPageOriginWidth === 0 || !this.elem) {
return this;
}
if (isUndef(width)) {
width = (this.elem.parentElement as HTMLElement).clientWidth;
}
this.elem.style.width = `${width}px`;
Promise.resolve().then(() => {
const tmpWidth = (this.pageHelper as HTMLElement).clientWidth;
if (this.width !== tmpWidth) {
this.width = tmpWidth;
const pageSizes: { [key: number]: { w: number, h: number } } = {};
this.pages.forEach((p, i) => {
p.resize(this.width);
pageSizes[i + 1] = {
w: tmpWidth,
h: p.getHeight()
};
});
this._render(true);
this.eventHandler.trigger(EVENTS.PAGE_RESIZE, new PVPageResizeEvent(this, pageSizes));
}
});
return this;
}
destroy() {
this.destroyed = true;
if (this.renderTimer) {
clearTimeout(this.renderTimer);
}
// 销毁页面
this.pages.forEach((page) => {
page.destroy();
});
this.pages.length = 0;
// 销毁viewer element
if (this.elem) {
(this.pageHelper as HTMLElement).remove();
this.pageHelper = null;
this.elem.removeEventListener('scroll', onscroll);
this.elem.removeEventListener('click', onclick);
this.elem.remove();
this.elem = null;
}
// 销毁pdf.js相关的变量
(this.dc as PDFDocumentProxy).destroy();
this.dc = null;
this.pdfTask = null;
}
}
function onclick(e: MouseEvent) {
const eventPath = getEventPath(e);
const pvElem = eventPath.filter((item: HTMLElement) => {
return item.className === CLASS_NAME;
})[0];
if (!pvElem) {
return;
}
const pv = pvElem['pv'];
if (!pv.eventHandler.hasListener(EVENTS.HIGHLIGHT_CLICK)) {
return;
}
if (eventPath.some((elem) => elem instanceof HTMLElement && elem.hasAttribute('data-page'))) {
let x = e.offsetX;
let y = e.offsetY;
let page = 0;
for (let i = 0; i < eventPath.length; i++) {
const elem = eventPath[i];
if (elem instanceof HTMLElement && elem.hasAttribute('data-page')) {
// @ts-ignore
page = +elem.getAttribute('data-page');
break;
} else {
x += elem.offsetLeft;
y += elem.offsetTop;
}
}
const highlights = pv.pages[page - 1].getHighlightsByPoint(x, y);
if (highlights.length > 0) {
pv.eventHandler.trigger(EVENTS.HIGHLIGHT_CLICK, new PVHighlightClickEvent(e, pv, highlights));
}
}
}
function onscroll(e: Event) {
const pvElem = e.target as HTMLElement;
// @ts-ignore
const pv = pvElem['pv'];
if (pv.renderTimer) {
clearTimeout(pv.renderTimer);
pv.renderTimer = null;
}
pv.renderTimer = setTimeout(() => {
pv._render();
}, 50);
pv.eventHandler.trigger(EVENTS.SCROLL, new PVScrollEvent(pv, pvElem['scrollTop'], pvElem['scrollLeft']));
}