UNPKG

document-viewer-ts

Version:

PDF and MS Doc viewer written in TypeScript for React and vanilla JavaScript

435 lines 20.8 kB
import { GlobalWorkerOptions, RenderingCancelledException, TextLayer, getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs'; const chevronLeft = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-left" viewBox="0 0 16 16"> <path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"/> </svg>`; const chevronRight = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-right" viewBox="0 0 16 16"> <path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/> </svg>`; const handleError = (containerDiv) => (err) => { renderErrorMessage(containerDiv)(`There was an error fetching your document. Please try again later. Error: ${err}`); }; const noop = () => undefined; // pdf.js 6 fetches its JBIG2/OpenJPEG/QCMS decoders — and the plain-JS fallbacks // that live beside them — from `wasmUrl`, which defaults to a page-relative // "wasm" and so 404s on every nested route. Derive it from the worker instead, // which the consumer already serves from a known location. const wasmUrlFor = (workerSrc) => String(workerSrc || '').replace(/[^/]*$/, 'wasm/'); const isCancelled = (err) => err instanceof RenderingCancelledException || (typeof err === 'object' && err !== null && err.name === 'RenderingCancelledException'); // pdf.js's own ceilings (AppOptions maxCanvasPixels / maxCanvasDim). Past // either one the browser hands back a blank canvas with no error, so a zoom // that would exceed them is rendered at the largest scale that fits. const maxCanvasPixels = 2 ** 25; const maxCanvasDim = 32767; const fittedScale = (pdfPage, scale) => { const { width, height } = pdfPage.getViewport({ scale }); const byArea = Math.sqrt(maxCanvasPixels / (width * height)); const bySide = Math.min(maxCanvasDim / width, maxCanvasDim / height); return scale * Math.min(1, byArea, bySide); }; const zoomValues = [50, 80, 100, 125, 150, 200, 300, 400]; const defaultWidth = 80; const PDFtoCSSConvert = 96 / 72; const defaultPageWidth = 700; const scaleTextLayer = async (textLayerDiv, textContent, pdfPage, canvas, viewport, renderScale, slot, isStillCurrent) => { const textLayerFragment = document.createElement('div'); textLayerFragment.className = 'textLayer'; // renderScale is the scale the viewport was actually built at, so this // reduces to cssWidth / rawPageWidth even when fittedScale clamped it. const scale = renderScale * (canvas.offsetWidth / viewport.width); const vs = pdfPage.getViewport({ scale }); const layer = new TextLayer({ textContentSource: textContent, container: textLayerFragment, viewport: vs, }); // A superseded run streams every item on the page before the generation // check can discard it; cancelling stops that work instead of wasting it. slot.current?.cancel(); slot.current = layer; await layer.render(); // A superseded run must not touch the DOM: two concurrent runs that both // cleared and refilled the layer are what garbled it on resize. if (!isStillCurrent()) return; textLayerDiv.replaceChildren(textLayerFragment); }; const createLoadingIndicator = () => { const container = document.createElement('div'); container.className = 'indicator-container'; const indicator = document.createElement('div'); indicator.className = 'lds-ring'; indicator.innerHTML = '<div></div><div></div><div></div><div></div></div>'; container.appendChild(indicator); return container; }; export const renderPDF = (containerDiv, documentUrl) => { const wrapperDiv = document.createElement('div'); wrapperDiv.className = 'wrapper'; containerDiv.appendChild(wrapperDiv); const loadingIndicator = createLoadingIndicator(); wrapperDiv.appendChild(loadingIndicator); const documentId = containerDiv.id; const canvasContainer = document.createElement('div'); canvasContainer.className = 'canvas-container'; const controlsDiv = document.createElement('div'); controlsDiv.className = 'viewer-controls'; const fullPageNumberDiv = document.createElement('div'); fullPageNumberDiv.className = 'page-number'; const pageNumberInput = document.createElement('input'); pageNumberInput.className = 'page-number-input'; pageNumberInput.type = 'number'; const pageDiv = document.createElement('div'); pageDiv.className = 'page-label'; const outOfDiv = document.createElement('div'); const pageCountDiv = document.createElement('div'); const nextButton = document.createElement('button'); nextButton.className = 'next-button'; const prevButton = document.createElement('button'); prevButton.className = 'prev-button'; const loadingTask = getDocument({ url: documentUrl, wasmUrl: wasmUrlFor(GlobalWorkerOptions.workerSrc), }); const zoomSelect = document.createElement('select'); zoomSelect.className = 'zoom-select'; zoomValues.forEach((v) => { const zoomOption = document.createElement('option'); zoomOption.value = `${v}`; zoomOption.textContent = `${v}%`; zoomSelect.appendChild(zoomOption); }); zoomSelect.value = `${defaultWidth}`; const getZoomVal = (originalPageWidth) => { const pageWidth = containerDiv.clientWidth * (parseInt(zoomSelect.value) / 100); const scaledBy = pageWidth / originalPageWidth; const scaleVal = scaledBy * 2.5 * PDFtoCSSConvert; return scaleVal; }; const canvas = document.createElement('canvas'); const textLayerDiv = document.createElement('div'); // pdf.js tracks live render tasks per canvas in an internal WeakSet and throws // if a second render starts while the first still holds it. `currentRenderTask` // is that single slot, `renderChain` serializes callers, and `requestedPage` // lets superseded requests drop out instead of queueing a redundant render. const controller = new AbortController(); const { signal } = controller; // JavaScript has no cancellable await, so resumption has to be made // conditional by hand. Awaiting through this rather than bare keeps the // guarded form the shorter one to write and the funnel the only exit. const upTo = (p) => p.then((a) => { signal.throwIfAborted(); return a; }); // pdf.js rejects in-flight work with a plain Error when the loading task is // destroyed, so a cancelled render is not identifiable from the error alone. const ignorable = (err) => signal.aborted || isCancelled(err); let currentRenderTask = null; let renderChain = Promise.resolve(); let requestedPage = 1; let currentPage = null; let textLayerGeneration = 0; const layerSlot = { current: null }; const refreshTextLayer = (page) => { // pdf.js sizes the layer as --total-scale-factor * the raw page width, so // this must come from the laid-out canvas: deriving it from the container // overshoots wherever .wrapper reserves a classic scrollbar. // A container that is not laid out gives a zero-width canvas, and the // text-layer scale below divides by it. if (canvas.offsetWidth === 0 || page.viewport.width === 0) return Promise.resolve(); containerDiv.style .setProperty('--scale-factor', `${canvas.offsetWidth / page.originalPageWidth}`); textLayerGeneration += 1; const generation = textLayerGeneration; return scaleTextLayer(textLayerDiv, page.textContent, page.pdfPage, canvas, page.viewport, page.renderScale, layerSlot, () => !signal.aborted && generation === textLayerGeneration); }; // One observer over a mutable current-page slot. Registering per page view // meant N pages produced N concurrent handlers on a single resize, and a // window listener misses the container being resized by anything other than // the viewport — a tab, accordion or modal revealing it, most importantly, // which is what left a hidden container stuck at a 0x0 canvas. let lastWidth = 0; let resizeFrame = 0; let rerenderQueued = false; let rerender = null; const onResize = () => { const width = containerDiv.clientWidth; // A width that did not change cannot move the text layer, and rebuilding it // streams every item on the page. if (currentPage === null || width === 0 || width === lastWidth) return; lastWidth = width; // An animated reveal or a dragged split pane changes width every frame, so // debounce to the frame rather than rebuilding once per callback. if (resizeFrame !== 0) cancelAnimationFrame(resizeFrame); resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; if (currentPage === null) return; // A container that was not laid out rendered a 0x0 canvas; scaling the // text layer over it cannot recover, only rendering the page again can. if (canvas.width === 0) { if (!rerenderQueued && rerender !== null) { rerenderQueued = true; rerender(); } return; } rerenderQueued = false; refreshTextLayer(currentPage).catch(noop); }); }; const observer = new ResizeObserver(onResize); observer.observe(containerDiv); signal.addEventListener('abort', () => { observer.disconnect(); if (resizeFrame !== 0) cancelAnimationFrame(resizeFrame); layerSlot.current?.cancel(); }); signal.addEventListener('abort', () => { if (currentRenderTask !== null) { currentRenderTask.cancel(); currentRenderTask = null; } currentPage = null; wrapperDiv.remove(); containerDiv.classList.remove('document-viewer-ts'); containerDiv.removeAttribute('tabIndex'); containerDiv.style.removeProperty('--scale-factor'); loadingTask.destroy().catch(noop); }); const destroy = () => { controller.abort(); }; void (async () => { try { const pdfDocument = await upTo(loadingTask.promise); wrapperDiv.removeChild(loadingIndicator); const isValidPage = (page) => page <= pdfDocument.numPages && page > 0; // initial viewer setup pageNumberInput.value = '1'; pageDiv.textContent = 'Page '; outOfDiv.textContent = '/'; pageCountDiv.textContent = `${pdfDocument.numPages}`; fullPageNumberDiv.appendChild(pageDiv); fullPageNumberDiv.appendChild(pageNumberInput); fullPageNumberDiv.appendChild(outOfDiv); fullPageNumberDiv.appendChild(pageCountDiv); // page container setup const pageContainer = document.createElement('div'); pageContainer.className = 'page-container'; pageContainer.style.width = `${defaultWidth}%`; canvasContainer.style.alignItems = 'center'; canvasContainer.appendChild(pageContainer); canvas.className = 'pdf-viewer-canvas'; canvas.id = `${documentId}-canvas`; pageContainer.appendChild(canvas); // text layer setup textLayerDiv.className = 'text-layer'; pageContainer.appendChild(textLayerDiv); nextButton.innerHTML = chevronRight; prevButton.innerHTML = chevronLeft; wrapperDiv.appendChild(controlsDiv); controlsDiv.appendChild(prevButton); controlsDiv.appendChild(fullPageNumberDiv); controlsDiv.appendChild(nextButton); controlsDiv.appendChild(zoomSelect); wrapperDiv.appendChild(canvasContainer); const renderPage = async (page) => { const pdfPage = await upTo(pdfDocument.getPage(page)); const originalPageWidth = Number(pdfPage.view[2]) - Number(pdfPage.view[0]) || defaultPageWidth; const renderScale = fittedScale(pdfPage, getZoomVal(originalPageWidth)); const viewport = pdfPage.getViewport({ scale: renderScale }); canvas.width = viewport.width; canvas.height = viewport.height; canvas.style.width = '100%'; const task = pdfPage.render({ canvas, viewport }); currentRenderTask = task; try { await upTo(task.promise); } finally { if (currentRenderTask === task) currentRenderTask = null; } const textContent = await upTo(pdfPage.getTextContent()); const previous = currentPage; currentPage = { textContent, pdfPage, viewport, originalPageWidth, renderScale }; await refreshTextLayer(currentPage); // pdf.js caches every page it hands out; without this a long official // statement retains the operator list and decoded images of every page // the reader visited. if (previous !== null && previous.pdfPage !== pdfPage) previous.pdfPage.cleanup(); }; const displayPage = (page) => { requestedPage = page; pageNumberInput.value = `${page}`; // Frees the canvas synchronously so the next chain link can claim it. if (currentRenderTask !== null) currentRenderTask.cancel(); renderChain = renderChain .then(() => (signal.aborted || page !== requestedPage ? undefined : renderPage(page))) .catch((err) => { if (ignorable(err)) return; handleError(containerDiv)(err); }); return renderChain; }; rerender = () => { void displayPage(requestedPage); }; zoomSelect.onchange = () => { const pageNumber = requestedPage; const zoomVal = parseInt(zoomSelect.value); pageContainer.style.width = `${zoomSelect.value}%`; if (zoomVal > 100) { canvasContainer.style.alignItems = 'flex-start'; } else { canvasContainer.style.alignItems = 'center'; } return displayPage(pageNumber); }; const skipPage = (direction) => () => { const nextPageNumber = requestedPage + direction; if (isValidPage(nextPageNumber)) { void displayPage(nextPageNumber); } }; nextButton.onclick = skipPage(1); prevButton.onclick = skipPage(-1); pageNumberInput.onchange = () => { const parsed = parseInt(pageNumberInput.value); const pageNumber = Number.isNaN(parsed) ? requestedPage : Math.max(Math.min(parsed, pdfDocument.numPages), 1); void displayPage(pageNumber); }; pageNumberInput.addEventListener('click', (e) => { e.stopPropagation(); }, { signal }); zoomSelect.addEventListener('click', (e) => { e.stopPropagation(); }, { signal }); containerDiv.addEventListener('keydown', (e) => { e.stopPropagation(); switch (e.key) { case ('ArrowLeft'): skipPage(-1)(); return; case ('ArrowRight'): skipPage(1)(); return; default: return; } }, { signal }); containerDiv.addEventListener('click', () => { containerDiv.focus(); }, { signal }); containerDiv.setAttribute('tabIndex', '1'); containerDiv.focus(); await displayPage(1); } catch (err) { if (ignorable(err)) return; // Releases the worker and the observer too; the wrapper goes with it. controller.abort(); handleError(containerDiv)(err); } })(); return destroy; }; const renderDocx = (containerDiv, documentUrl) => { const microsoftViewer = document.createElement('iframe'); microsoftViewer.width = '100%'; microsoftViewer.height = '100%'; microsoftViewer.setAttribute('frameborder', '0'); const params = new URLSearchParams({ src: documentUrl }); microsoftViewer.setAttribute('src', `https://view.officeapps.live.com/op/embed.aspx?${params.toString()}`); containerDiv.appendChild(microsoftViewer); }; const renderTxt = (containerDiv, documentUrl) => { const embed = document.createElement('embed'); embed.className = 'txt-embed'; embed.setAttribute('src', documentUrl); containerDiv.appendChild(embed); }; const renderErrorMessage = (containerDiv) => (errorMessage) => { const errorDiv = document.createElement('div'); errorDiv.className = 'error-message'; errorDiv.textContent = errorMessage; containerDiv.appendChild(errorDiv); }; // `init` renders on call and again on window load, and React StrictMode mounts // twice; without this the earlier render's worker and resize listener outlive // the DOM they were built for. const teardowns = new WeakMap(); const renderDocumentIn = (workerSrc) => (containerDiv) => { try { containerDiv.innerHTML = ''; const documentUrl = containerDiv.getAttribute('data-document-url'); containerDiv.classList.add('document-viewer-ts'); if (!documentUrl) throw new Error('No document url specified'); const splitOnPeriods = documentUrl.split('.'); const extension = splitOnPeriods[(splitOnPeriods.length - 1)]?.split('?')[0]?.toLowerCase(); switch (extension) { case 'pdf': try { (() => globalThis)(); new File([], 'test.txt'); GlobalWorkerOptions.workerSrc = workerSrc; return renderPDF(containerDiv, documentUrl); } catch { renderErrorMessage(containerDiv)('Your browser does not support showing PDF previews. Click the download button to view this document.'); return noop; } case 'doc': case 'docx': case 'ppt': case 'pptx': case 'xls': case 'xlsx': case 'xlt': case 'xlsm': case 'xlw': case 'pps': case 'ppxs': case 'ppsm': case 'sldx': case 'sldm': renderDocx(containerDiv, documentUrl); return noop; case 'txt': renderTxt(containerDiv, documentUrl); return noop; default: throw new Error('This file type is not supported for viewing in a web browser. Please click the “Download” button to view this document.'); } } catch (err) { handleError(containerDiv)(err); return noop; } }; export const renderDocument = (workerSrc) => (containerDiv) => { teardowns.get(containerDiv)?.(); const destroy = renderDocumentIn(workerSrc)(containerDiv); const teardown = () => { // A superseded render was already torn down when its replacement claimed // the container; running this again would blank the live viewer. if (teardowns.get(containerDiv) !== teardown) return; teardowns.delete(containerDiv); destroy(); // Only the pdf path owns a destroy; the iframe, embed and error renderers // append straight to the container and would otherwise outlive teardown. containerDiv.replaceChildren(); }; teardowns.set(containerDiv, teardown); return teardown; }; const loadDocuments = (workerSrc) => () => { const containerDivs = document.getElementsByClassName('viewer-container'); Array.from(containerDivs).forEach(renderDocument(workerSrc)); }; export const init = (workerSrc) => { loadDocuments(workerSrc)(); window.addEventListener('load', loadDocuments(workerSrc)); }; //# sourceMappingURL=base.js.map