simbathesailor.DEV

Solving Pdf Preview Nightmare

pdf
pdf-preview
reactjs

January 27, 2021

Photo by https://unsplash.com/@austindistel

Photo from https://unsplash.com/@austindistel

Do you ever has the need to preview pdf on web ?. It’s so common but still draws every ounce of energy from a developer to make it work properly on every device.

Why it has to be so hard ?

My Attempts

  1. Just open the pdf in the new tab and the browser will take care of it to preview correctly.

Problem: It’s not always the best experience for the user. You don’t want user to switch the context by moving to a separate tab. It looks much bad when it happens on mobile device. The better experience would be on the same page without sacrificing context.

  1. Try to open the document in a iframe. The iframe can be bit tricky across browsers. There can be security restrictions and disallow policy for certain pdfs. E.g When I tried opening some sample pdf which were opening fine on desktop device were not rendering at all on mobile device.

This situation can be very difficult. You can’t do much there to make it work.


Can we not have something straightforward where I just provide pdf URL and it takes care of rest. Renders properly on every device and most of the browsers.

With the above goal I started my search for the best possible library on internet. Nothing sort of helped until I found Mozilla pdf.js. It makes use of Readable streams

After few hours of digging and reading through source I was like :

Found it

But then was it ready for my Reactjs APP ?

Nopes. Not at all.

Hence I have to write my own port of mozilla pdf.js for reactjs using examples from https://github.com/mozilla/pdf.js/blob/master/examples

Pdf Preview implementation

I wanted a pdf mobile previewer and it seemed that mozilla pdf.js has example folder available.

I just needed to Reactify it.

First step is to add certain script to your html.

Add following scripts in your body tag.

<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@2.5.207/build/pdf.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@2.1.266/web/pdf_viewer.min.js"></script>

Once these scripts are added properly we should have pdfjsViewer and pdfjsLib available in the global scope.

Note: Make sure we get it right, before we move ahead

Now we will create a React component called PdfPreview.

Let’s see the boiler-plate code for the PdfPreview component:

function PdfPreview(props) {
const { docUrl, uniqueContainerId = "pdf-preview", styles } = props;
useEffect(() => {
// Plain javascript mozilla pdf.js initialization code can be placed here
}, [docUrl]);
return (
<PDFViewCustomContainer
styles={styles}
uniqueContainerId={uniqueContainerId}
id={`preview-container-${
uniqueContainerId ? `${uniqueContainerId}` : ""
}`}
>
{/* {!loaded && <Loader dark />} */}
<header>
<h1 id={`title-${uniqueContainerId}`} />
</header>
<div id={`viewerContainer-${uniqueContainerId}`}>
<div id="viewer" className="pdfViewer" />
</div>
<div id={`loadingBar-${uniqueContainerId}`}>
<div className="progress" />
<div className="glimmer" />
</div>
<div id={`errorWrapper-${uniqueContainerId}`} hidden="true">
<div id={`errorMessageLeft-${uniqueContainerId}`}>
<span id={`errorMessage-${uniqueContainerId}`} />
<button id={`errorShowMore-${uniqueContainerId}`}>
More Information
</button>
<button id={`errorShowLess-${uniqueContainerId}`}>
Less Information
</button>
</div>
<div id={`errorMessageRight-${uniqueContainerId}`}>
<button id={`errorClose-${uniqueContainerId}`}>Close</button>
</div>
<div className="clearBoth" />
<textarea
id={`errorMoreInfo-${uniqueContainerId}`}
hidden="true"
readOnly="readonly"
defaultValue={""}
/>
</div>
<footer>
<button
className="toolbarButton pageUp"
title="Previous Page"
id={`previous-${uniqueContainerId}`}
/>
<button
className="toolbarButton pageDown"
title="Next Page"
id={`next-${uniqueContainerId}`}
/>
<input
type="number"
id={`pageNumber-${uniqueContainerId}`}
className="toolbarField pageNumber"
defaultValue={1}
size={4}
min={1}
/>
<button
className="toolbarButton zoomOut"
title="Zoom Out"
id={`zoomOut-${uniqueContainerId}`}
/>
<button
className="toolbarButton zoomIn"
title="Zoom In"
id={`zoomIn-${uniqueContainerId}`}
/>
</footer>
</PDFViewCustomContainer>
);
}
export default PdfPreview;

This component takes in three props:

docUrl: String -> It is the pdf URL.

uniqueContainerId: String -> It is uniqueContainerId 

styles: Object -> styles need to appended to the base styles

Take some time to go through the code above. Nothing fancy just some JSX and an useEffect.

We need to add pdf preview initialization inside useEffect.

Now let’s add Mozilla pdf.js code copied from their example folder with slight changes. The changes made are just to accomodate handling when pdf Url itself is not there or not valid.

The code below can seem daunting at first, but most of it is just handling the pdf preview various case and updating UI. I have copied most of the content directly from mozilla pdf.js examples. They have done a splendid work by putting up great examples.

import React, { useState, useEffect } from "react";
import styled from "styled-components";
import PropTypes from "prop-types";
function getCustomIdname(props) {
return props.uniqueContainerId ? `-${props.uniqueContainerId}` : "-";
}
export const PDFViewCustomContainer = styled.div`
// default css, not add it here to avoif large gist
${(props) => props.styles || ""}
`;
function PdfPreview(props) {
const {
docUrl,
uniqueContainerId = "pdf-preview",
footerStyles,
styles,
} = props;
useEffect(() => {
/**
* [description]
*/
const pdfjsLib = window.pdfjsLib;
const pdfjsViewer = window.pdfjsViewer;
const USE_ONLY_CSS_ZOOM = true;
const TEXT_LAYER_MODE = 0; // DISABLE
const MAX_IMAGE_SIZE = 1024 * 1024;
const CMAP_URL = "../node_modules/pdfjs-dist/cmaps/";
const CMAP_PACKED = true;
// pdfjsLib.GlobalWorkerOptions.workerSrc =
// "../../node_modules/pdfjs-dist/build/pdf.worker.js";
const DEFAULT_SCALE_DELTA = 1.1;
const MIN_SCALE = 0.25;
const MAX_SCALE = 10.0;
const DEFAULT_SCALE_VALUE = "auto";
const PDFViewerApplication = {
pdfLoadingTask: null,
pdfDocument: null,
pdfViewer: null,
pdfHistory: null,
pdfLinkService: null,
eventBus: null,
/**
* Opens PDF document specified by URL.
* @returns {Promise} - Returns the promise, which is resolved when document
* is opened.
*/
open: function (params) {
if (this.pdfLoadingTask) {
// We need to destroy already opened document
return this.close().then(
function () {
// ... and repeat the open() call.
return this.open(params);
}.bind(this)
);
}
const url = params.url;
const self = this;
this.setTitleUsingUrl(url);
// Loading document.
const loadingTask = pdfjsLib.getDocument({
url: url,
maxImageSize: MAX_IMAGE_SIZE,
cMapUrl: CMAP_URL,
cMapPacked: CMAP_PACKED,
});
this.pdfLoadingTask = loadingTask;
loadingTask.onProgress = function (progressData) {
self.progress(progressData.loaded / progressData.total);
};
return loadingTask.promise.then(
function (pdfDocument) {
// Document loaded, specifying document for the viewer.
self.pdfDocument = pdfDocument;
self.pdfViewer.setDocument(pdfDocument);
self.pdfLinkService.setDocument(pdfDocument);
self.pdfHistory.initialize({
fingerprint: pdfDocument.fingerprint,
});
self.loadingBar.hide();
setLoaded(true);
self.setTitleUsingMetadata(pdfDocument);
},
function (exception) {
const message = exception && exception.message;
self.loadingBar.hide();
}
);
},
/**
* Closes opened PDF document.
* @returns {Promise} - Returns the promise, which is resolved when all
* destruction is completed.
*/
close: function () {
const errorWrapper = document.getElementById(
`errorWrapper-${uniqueContainerId}`
);
errorWrapper.setAttribute("hidden", "true");
if (!this.pdfLoadingTask) {
return Promise.resolve();
}
const promise = this.pdfLoadingTask.destroy();
this.pdfLoadingTask = null;
if (this.pdfDocument) {
this.pdfDocument = null;
this.pdfViewer.setDocument(null);
this.pdfLinkService.setDocument(null, null);
if (this.pdfHistory) {
this.pdfHistory.reset();
}
}
return promise;
},
get loadingBar() {
const bar = new pdfjsViewer.ProgressBar(
`#loadingBar${getCustomIdname({
uniqueContainerId,
})}`,
{}
);
return pdfjsLib.shadow(this, "loadingBar", bar);
},
setTitleUsingUrl: function pdfViewSetTitleUsingUrl(url) {
this.url = url;
const title = pdfjsLib.getFilenameFromUrl(url) || url;
try {
title = decodeURIComponent(title);
} catch (e) {
// decodeURIComponent may throw URIError,
// fall back to using the unprocessed url in that case
}
this.setTitle(title);
},
setTitleUsingMetadata: function (pdfDocument) {
const self = this;
pdfDocument.getMetadata().then(function (data) {
const info = data.info,
metadata = data.metadata;
self.documentInfo = info;
self.metadata = metadata;
// Provides some basic debug information
console.log(
"PDF " +
pdfDocument.fingerprint +
" [" +
info.PDFFormatVersion +
" " +
(info.Producer || "-").trim() +
" / " +
(info.Creator || "-").trim() +
"]" +
" (PDF.js: " +
(pdfjsLib.version || "-") +
")"
);
let pdfTitle;
if (metadata && metadata.has("dc:title")) {
const title = metadata.get("dc:title");
// Ghostscript sometimes returns 'Untitled', so prevent setting the
// title to 'Untitled.
if (title !== "Untitled") {
pdfTitle = title;
}
}
if (!pdfTitle && info && info.Title) {
pdfTitle = info.Title;
}
if (pdfTitle) {
self.setTitle(pdfTitle + " - " + document.title);
}
});
},
setTitle: function pdfViewSetTitle(title) {
document.title = title;
document.getElementById(
`title-${uniqueContainerId}`
).textContent = title;
},
error: function pdfViewError(message, moreInfo) {
const l10n = this.l10n;
const moreInfoText = [
l10n.get(
"error_version_info",
{ version: pdfjsLib.version || "?", build: pdfjsLib.build || "?" },
"PDF.js v{{version}} (build: {{build}})"
),
];
if (moreInfo) {
moreInfoText.push(
l10n.get(
"error_message",
{ message: moreInfo.message },
"Message: {{message}}"
)
);
if (moreInfo.stack) {
moreInfoText.push(
l10n.get(
"error_stack",
{ stack: moreInfo.stack },
"Stack: {{stack}}"
)
);
} else {
if (moreInfo.filename) {
moreInfoText.push(
l10n.get(
"error_file",
{ file: moreInfo.filename },
"File: {{file}}"
)
);
}
if (moreInfo.lineNumber) {
moreInfoText.push(
l10n.get(
"error_line",
{ line: moreInfo.lineNumber },
"Line: {{line}}"
)
);
}
}
}
const errorWrapper = document.getElementById(
`errorWrapper-${uniqueContainerId}`
);
errorWrapper.removeAttribute("hidden");
const errorMessage = document.getElementById(
`errorMessage-${uniqueContainerId}`
);
errorMessage.textContent = message;
const closeButton = document.getElementById(
`errorClose-${uniqueContainerId}`
);
closeButton.onclick = function () {
errorWrapper.setAttribute("hidden", "true");
};
const errorMoreInfo = document.getElementById(
`errorMoreInfo-${uniqueContainerId}`
);
const moreInfoButton = document.getElementById(
`errorShowMore-${uniqueContainerId}`
);
const lessInfoButton = document.getElementById(
`errorShowLess-${uniqueContainerId}`
);
moreInfoButton.onclick = function () {
errorMoreInfo.removeAttribute("hidden");
moreInfoButton.setAttribute("hidden", "true");
lessInfoButton.removeAttribute("hidden");
errorMoreInfo.style.height = errorMoreInfo.scrollHeight + "px";
};
lessInfoButton.onclick = function () {
errorMoreInfo.setAttribute("hidden", "true");
moreInfoButton.removeAttribute("hidden");
lessInfoButton.setAttribute("hidden", "true");
};
moreInfoButton.removeAttribute("hidden");
lessInfoButton.setAttribute("hidden", "true");
Promise.all(moreInfoText).then(function (parts) {
errorMoreInfo.value = parts.join("\n");
});
},
progress: function pdfViewProgress(level) {
const percent = Math.round(level * 100);
// Updating the bar if value increases.
if (percent > this.loadingBar.percent || isNaN(percent)) {
this.loadingBar.percent = percent;
}
},
get pagesCount() {
return this.pdfDocument.numPages;
},
get page() {
return this.pdfViewer.currentPageNumber;
},
set page(val) {
this.pdfViewer.currentPageNumber = val;
},
zoomIn: function pdfViewZoomIn(ticks) {
let newScale = this.pdfViewer.currentScale;
do {
newScale = (newScale * DEFAULT_SCALE_DELTA).toFixed(2);
newScale = Math.ceil(newScale * 10) / 10;
newScale = Math.min(MAX_SCALE, newScale);
} while (--ticks && newScale < MAX_SCALE);
this.pdfViewer.currentScaleValue = newScale;
},
zoomOut: function pdfViewZoomOut(ticks) {
let newScale = this.pdfViewer.currentScale;
do {
newScale = (newScale / DEFAULT_SCALE_DELTA).toFixed(2);
newScale = Math.floor(newScale * 10) / 10;
newScale = Math.max(MIN_SCALE, newScale);
} while (--ticks && newScale > MIN_SCALE);
this.pdfViewer.currentScaleValue = newScale;
},
initUI: function pdfViewInitUI() {
const eventBus = new pdfjsViewer.EventBus();
this.eventBus = eventBus;
const linkService = new pdfjsViewer.PDFLinkService({
eventBus: eventBus,
});
this.pdfLinkService = linkService;
this.l10n = pdfjsViewer.NullL10n;
const container = document.getElementById(
`viewerContainer-${uniqueContainerId}`
);
const pdfViewer = new pdfjsViewer.PDFViewer({
container: container,
eventBus: eventBus,
linkService: linkService,
l10n: this.l10n,
useOnlyCssZoom: USE_ONLY_CSS_ZOOM,
textLayerMode: TEXT_LAYER_MODE,
});
this.pdfViewer = pdfViewer;
linkService.setViewer(pdfViewer);
this.pdfHistory = new pdfjsViewer.PDFHistory({
eventBus: eventBus,
linkService: linkService,
});
linkService.setHistory(this.pdfHistory);
document
.getElementById(`previous-${uniqueContainerId}`)
.addEventListener("click", function () {
PDFViewerApplication.page--;
});
document
.getElementById(`next-${uniqueContainerId}`)
.addEventListener("click", function () {
PDFViewerApplication.page++;
});
document
.getElementById(`zoomIn-${uniqueContainerId}`)
.addEventListener("click", function () {
PDFViewerApplication.zoomIn();
});
document
.getElementById(`zoomOut-${uniqueContainerId}`)
.addEventListener("click", function () {
PDFViewerApplication.zoomOut();
});
document
.getElementById(`pageNumber-${uniqueContainerId}`)
.addEventListener("click", function () {
this.select();
});
document
.getElementById(`pageNumber-${uniqueContainerId}`)
.addEventListener("change", function () {
PDFViewerApplication.page = this.value | 0;
// Ensure that the page number input displays the correct value,
// even if the value entered by the user was invalid
// (e.g. a floating point number).
if (this.value !== PDFViewerApplication.page.toString()) {
this.value = PDFViewerApplication.page;
}
});
eventBus.on("pagesinit", function () {
// We can use pdfViewer now, e.g. let's change default scale.
pdfViewer.currentScaleValue = DEFAULT_SCALE_VALUE;
});
eventBus.on(
"pagechanging",
function (evt) {
const page = evt.pageNumber;
const numPages = PDFViewerApplication.pagesCount;
document.getElementById(
`pageNumber-${uniqueContainerId}`
).value = page;
document.getElementById(`previous-${uniqueContainerId}`).disabled =
page <= 1;
document.getElementById(`next-${uniqueContainerId}`).disabled =
page >= numPages;
},
true
);
},
};
(function animationStartedClosure() {
// The offsetParent is not set until the PDF.js iframe or object is visible.
// Waiting for first animation.
PDFViewerApplication.animationStartedPromise = new Promise(function (
resolve
) {
window.requestAnimationFrame(resolve);
});
})();
// We need to delay opening until all HTML is loaded.
if (docUrl) {
PDFViewerApplication.animationStartedPromise.then(function () {
const loadingIconArr = document.querySelectorAll(".loadingIcon");
PDFViewerApplication.open({
url: docUrl,
});
});
}
/**
* [des
* cription]
*/
if (docUrl) {
PDFViewerApplication.initUI();
}
return () => {};
}, [docUrl]);
return (
<PDFViewCustomContainer
styles={styles}
uniqueContainerId={uniqueContainerId}
id={`preview-container-${
uniqueContainerId ? `${uniqueContainerId}` : ""
}`}
>
/**Your JSX already mentioned in above snippet**/
</PDFViewCustomContainer>
);
}
export default PdfPreview;

If everything worked you should see the pdf rendered properly.

But you all must be thinking :

Dude , without Demo

Not done

Ohh alrighty !! Here is your demo below. Feel free to check the code, fork it and play with it.

I spent more than 6-7 hours to find out the solution. I hope someone who finds it will not.

The best part of this solution is now it is supported across devices and modern browsers.There will be surely be certain browsers where it might fail, but haven’t found any yet. Tested it on mozilla(82.0.3 (64-bit)) , chrome(Version 87.0.4280.141), safari (Version 13.1.3 (15609.4.1)) for both desktop and mobile versions.

I wrote this article for my future self and for others, because I know that pdf preview is very common use case and will comeback often.

This pdf previewer for react is not packaged yet. May package it if I get good response or someone else can try it doing.

Thanks

Best of luck

Helpful Links : There are no good Reactjs ports available. There was react-pdf-js library , but was very restrictive in the way it can be used.

Join the discussion