Commit 77d30231 authored by Robert Knight's avatar Robert Knight

Make logic for waiting for PDFs to load work in PDF.js >= 2.5.207

f96af43d added support for PDF.js >= v2.5.207 by changing an event
listener to use PDF.js's internal event bus rather than DOM events.
However there was another DOM event listener for the `pagesloaded` event
in `src/annotator/anchoring/pdf.js` that should also have been updated but was
overlooked. This didn't cause a problem in testing with the dev server because
the test documents load quickly enough that they are already loaded by the time
the client's anchoring logic ran.

This commit updates the way that the client listens for events from
PDF.js to use the event bus where available and only fall back to the
DOM in versions of PDF.js that don't support it.

 - Use PDF.js's event bus to listen for `documentload`/`documentloaded`
   and `pagesloaded` events

 - Add a fallback method to wait for event bus to become available in
   versions of PDF.js which support the eventBus but don't have the
   `initializedPromise` API

 - Improve the documentation around which versions of PDF.js support
   different event types and event dispatch methods

 - Add tests to cover the behavior from different releases of PDF.js

For an overview of the different versions of PDF.js that the client
needs to support, see https://github.com/hypothesis/client/issues/2643.
parent da788daf
...@@ -75,10 +75,21 @@ async function getPageView(pageIndex) { ...@@ -75,10 +75,21 @@ async function getPageView(pageIndex) {
// a "pdfPage" property. // a "pdfPage" property.
pageView = await new Promise(resolve => { pageView = await new Promise(resolve => {
const onPagesLoaded = () => { const onPagesLoaded = () => {
if (pdfViewer.eventBus) {
pdfViewer.eventBus.off('pagesloaded', onPagesLoaded);
} else {
document.removeEventListener('pagesloaded', onPagesLoaded); document.removeEventListener('pagesloaded', onPagesLoaded);
}
resolve(pdfViewer.getPageView(pageIndex)); resolve(pdfViewer.getPageView(pageIndex));
}; };
if (pdfViewer.eventBus) {
pdfViewer.eventBus.on('pagesloaded', onPagesLoaded);
} else {
// Old PDF.js versions (< 1.6.210) use DOM events.
document.addEventListener('pagesloaded', onPagesLoaded); document.addEventListener('pagesloaded', onPagesLoaded);
}
}); });
} }
......
...@@ -6,9 +6,12 @@ ...@@ -6,9 +6,12 @@
* easier to debug than the full PDF.js viewer application. * easier to debug than the full PDF.js viewer application.
* *
* The general structure is to have `Fake{OriginalClassName}` classes for * The general structure is to have `Fake{OriginalClassName}` classes for
* each of the relevant classes in PDF.js. * each of the relevant classes in PDF.js. The APIs of the fakes should conform
* to the corresponding interfaces defined in `src/types/pdfjs.js`.
*/ */
import { TinyEmitter as EventEmitter } from 'tiny-emitter';
import RenderingStates from '../../pdfjs-rendering-states'; import RenderingStates from '../../pdfjs-rendering-states';
/** /**
...@@ -124,6 +127,8 @@ class FakePDFViewer { ...@@ -124,6 +127,8 @@ class FakePDFViewer {
this._pages = []; this._pages = [];
this.viewer = this._container; this.viewer = this._container;
this.eventBus = new EventEmitter();
} }
get pagesCount() { get pagesCount() {
...@@ -170,11 +175,17 @@ class FakePDFViewer { ...@@ -170,11 +175,17 @@ class FakePDFViewer {
} }
/** /**
* Dispatch a DOM event to notify observers that some event has occurred * Dispatch an event to notify observers that some event has occurred
* in the PDF viewer. * in the PDF viewer.
*/ */
notify(eventName) { notify(eventName, { eventDispatch = 'eventBus' } = {}) {
this._container.dispatchEvent(new Event(eventName, { bubbles: true })); if (eventDispatch === 'eventBus') {
this.eventBus.emit(eventName);
} else if (eventDispatch === 'dom') {
this._container.dispatchEvent(
new CustomEvent(eventName, { bubbles: true })
);
}
} }
_checkBounds(index) { _checkBounds(index) {
......
...@@ -295,15 +295,28 @@ describe('annotator/anchoring/pdf', function () { ...@@ -295,15 +295,28 @@ describe('annotator/anchoring/pdf', function () {
// `PDFViewer.getPageView` returns a nullish value. // `PDFViewer.getPageView` returns a nullish value.
description: 'page view not loaded', description: 'page view not loaded',
pageView: undefined, pageView: undefined,
eventDispatch: 'eventBus',
}, },
{ {
// `PDFPageViewer.getPageView` returns a `PDFPageView`, but the associated // `PDFPageViewer.getPageView` returns a `PDFPageView`, but the associated
// page is not ready yet and so the `pdfPage` property is missing. // page is not ready yet and so the `pdfPage` property is missing.
description: 'page view PDF page not ready', description: 'page view PDF page not ready',
pageView: {}, pageView: {},
eventDispatch: 'eventBus',
}, },
].forEach(({ description, pageView }) => { {
// Older version of PDF.js (< 1.6.210) using DOM events.
description: 'old PDF.js version',
pageView: undefined,
eventDispatch: 'dom',
},
].forEach(({ description, pageView, eventDispatch }) => {
it(`waits until page views are ready (${description})`, async () => { it(`waits until page views are ready (${description})`, async () => {
if (eventDispatch === 'dom') {
// To simulate versions of PDF.js < 1.6.210, remove the `eventBus` API.
delete viewer.pdfViewer.eventBus;
}
viewer.pdfViewer.setCurrentPage(1); viewer.pdfViewer.setCurrentPage(1);
// Simulate PDF viewer not having fully loaded yet. // Simulate PDF viewer not having fully loaded yet.
...@@ -323,7 +336,7 @@ describe('annotator/anchoring/pdf', function () { ...@@ -323,7 +336,7 @@ describe('annotator/anchoring/pdf', function () {
// view, but block because it is not ready yet. // view, but block because it is not ready yet.
await delay(10); await delay(10);
getPageView.restore(); getPageView.restore();
viewer.pdfViewer.notify('pagesloaded'); viewer.pdfViewer.notify('pagesloaded', { eventDispatch });
// Check that anchoring completes successfully when the document has // Check that anchoring completes successfully when the document has
// loaded. // loaded.
......
...@@ -18,6 +18,36 @@ import { normalizeURI } from '../util/url'; ...@@ -18,6 +18,36 @@ import { normalizeURI } from '../util/url';
* part of the content if the PDF file does not have a File Identifier. * part of the content if the PDF file does not have a File Identifier.
*/ */
/**
* Wait for a PDFViewerApplication to be initialized.
*
* @param {PDFViewerApplication} app
* @return {Promise<void>}
*/
function pdfViewerInitialized(app) {
// `initializedPromise` was added in PDF.js v2.4.456.
// See https://github.com/mozilla/pdf.js/pull/11607. In earlier versions the
// `initialized` property can be queried.
if (app.initializedPromise) {
return app.initializedPromise;
} else if (app.initialized) {
return Promise.resolve();
} else {
// PDF.js < v2.4.456. The recommended approach is to listen for a `localized`
// DOM event, but this assumes that PDF.js has been configured to publish
// events to the DOM. Here we simply poll `app.initialized` because it is
// easier.
return new Promise(resolve => {
const timeout = setInterval(() => {
if (app.initialized) {
clearTimeout(timeout);
resolve();
}
}, 5);
});
}
}
/** /**
* PDFMetadata extracts metadata about a loading/loaded PDF document from a * PDFMetadata extracts metadata about a loading/loaded PDF document from a
* PDF.js PDFViewerApplication object. * PDF.js PDFViewerApplication object.
...@@ -38,38 +68,41 @@ export default class PDFMetadata { ...@@ -38,38 +68,41 @@ export default class PDFMetadata {
*/ */
constructor(app) { constructor(app) {
/** @type {Promise<PDFViewerApplication>} */ /** @type {Promise<PDFViewerApplication>} */
this._loaded = new Promise(resolve => { this._loaded = pdfViewerInitialized(app).then(() => {
// Check if document has already loaded.
if (app.downloadComplete) {
return app;
}
return new Promise(resolve => {
const finish = () => { const finish = () => {
if (app.eventBus) {
app.eventBus.off('documentload', finish);
app.eventBus.off('documentloaded', finish);
} else {
window.removeEventListener('documentload', finish); window.removeEventListener('documentload', finish);
window.removeEventListener('documentloaded', finish); }
app.eventBus?.off('documentloaded', finish);
resolve(app); resolve(app);
}; };
if (app.downloadComplete) {
resolve(app);
} else {
// Listen for "documentloaded" event which signals that the document // Listen for "documentloaded" event which signals that the document
// has been downloaded and the first page has been rendered. // has been downloaded and the first page has been rendered.
if (app.eventBus) {
// PDF.js >= v1.6.210 dispatch events via an internal event bus.
// PDF.js < v2.5.207 also dispatches events to the DOM.
// Newer versions of PDF.js (>= v2.5.207) report events only via // `documentloaded` is the preferred event in PDF.js >= v2.0.943.
// the PDFViewerApplication's own event bus.
app.initializedPromise?.then(() => {
app.eventBus?.on('documentloaded', finish);
});
// Older versions of PDF.js (< v2.5.207) dispatch events to the DOM
// instead or as well.
// PDF.js >= v2.0.943.
// See https://github.com/mozilla/pdf.js/commit/7bc4bfcc8b7f52b14107f0a551becdf01643c5c2 // See https://github.com/mozilla/pdf.js/commit/7bc4bfcc8b7f52b14107f0a551becdf01643c5c2
window.addEventListener('documentloaded', finish); app.eventBus.on('documentloaded', finish);
// PDF.js < v2.0.943. // `documentload` is dispatched by PDF.js < v2.1.266.
app.eventBus.on('documentload', finish);
} else {
// PDF.js < v1.6.210 dispatches events only to the DOM.
window.addEventListener('documentload', finish); window.addEventListener('documentload', finish);
} }
}); });
});
} }
/** /**
......
...@@ -42,13 +42,25 @@ class FakePDFViewerApplication { ...@@ -42,13 +42,25 @@ class FakePDFViewerApplication {
/** /**
* Initialize the "PDF viewer" as it would be when loading a document or * Initialize the "PDF viewer" as it would be when loading a document or
* when a document fails to load. * when a document fails to load.
*
* @param {string} url - Fake PDF URL
* @param {Object} options -
* Options to simulate APIs of different versions of PDF.js.
*
* @prop {boolean} domEvents - Whether events are emitted on the DOM
* @prop {boolean} eventBusEvents - Whether the `eventBus` API is enabled
* @prop {boolean} initializedPromise - Whether the `initializedPromise` API is enabled
*/ */
constructor(url = '', { domEvents = false, eventBusEvents = true } = {}) { constructor(
url = '',
{ domEvents = false, eventBusEvents = true, initializedPromise = true } = {}
) {
this.url = url; this.url = url;
this.documentInfo = undefined; this.documentInfo = undefined;
this.metadata = undefined; this.metadata = undefined;
this.pdfDocument = null; this.pdfDocument = null;
this.dispatchDOMEvents = domEvents; this.dispatchDOMEvents = domEvents;
this.initialized = false;
// Use `EventEmitter` as a fake version of PDF.js's `EventBus` class as the // Use `EventEmitter` as a fake version of PDF.js's `EventBus` class as the
// API for subscribing to events is the same. // API for subscribing to events is the same.
...@@ -56,9 +68,16 @@ class FakePDFViewerApplication { ...@@ -56,9 +68,16 @@ class FakePDFViewerApplication {
this.eventBus = new EventEmitter(); this.eventBus = new EventEmitter();
} }
this.initializedPromise = new Promise(resolve => { const initPromise = new Promise(resolve => {
this._resolveInitializedPromise = resolve; this._resolveInitializedPromise = () => {
this.initialized = true;
resolve();
};
}); });
if (initializedPromise) {
this.initializedPromise = initPromise;
}
} }
/** /**
...@@ -110,35 +129,54 @@ function delay(ms) { ...@@ -110,35 +129,54 @@ function delay(ms) {
describe('annotator/plugin/pdf-metadata', function () { describe('annotator/plugin/pdf-metadata', function () {
[ [
{ {
// Oldest PDF.js versions (pre-2.x) // PDF.js < 1.6.210: `documentload` event dispatched via DOM.
eventName: 'documentload', eventName: 'documentload',
domEvents: true, domEvents: true,
eventBusEvents: false, eventBusEvents: false,
initializedPromise: false,
}, },
{ {
// Newer PDF.js versions (~ < 2.5.x) // PDF.js >= 1.6.210: Event dispatch moved to internal event bus.
eventName: 'documentload',
domEvents: false,
eventBusEvents: true,
initializedPromise: false,
},
{
// PDF.js >= 2.1.266: Deprecated `documentload` event was removed.
eventName: 'documentloaded', eventName: 'documentloaded',
domEvents: true, domEvents: false,
eventBusEvents: false, eventBusEvents: true,
initializedPromise: false,
}, },
{ {
// Current PDF.js versions (>= 2.5.x) // PDF.js >= 2.4.456: `initializedPromise` API was introduced.
eventName: 'documentloaded', eventName: 'documentloaded',
domEvents: false, domEvents: false,
eventBusEvents: true, eventBusEvents: true,
initializedPromise: true,
}, },
].forEach(({ eventName, domEvents = false, eventBusEvents = false }, i) => { ].forEach(
({ eventName, domEvents, eventBusEvents, initializedPromise }, i) => {
it(`waits for PDF to load (${i})`, async () => { it(`waits for PDF to load (${i})`, async () => {
const fakeApp = new FakePDFViewerApplication('', { const fakeApp = new FakePDFViewerApplication('', {
domEvents, domEvents,
eventBusEvents, eventBusEvents,
initializedPromise,
}); });
const pdfMetadata = new PDFMetadata(fakeApp); const pdfMetadata = new PDFMetadata(fakeApp);
fakeApp.completeInit(); fakeApp.completeInit();
// Give `PDFMetadata` a chance to register the "documentloaded" event listener. // Request the PDF URL before the document has finished loading.
await delay(0); const uriPromise = pdfMetadata.getUri();
// Simulate a short delay in completing PDF.js initialization and
// loading the PDF.
//
// Note that this delay is longer than the `app.initialized` polling
// interval in `pdfViewerInitialized`.
await delay(10);
fakeApp.finishLoading({ fakeApp.finishLoading({
eventName, eventName,
...@@ -146,12 +184,19 @@ describe('annotator/plugin/pdf-metadata', function () { ...@@ -146,12 +184,19 @@ describe('annotator/plugin/pdf-metadata', function () {
fingerprint: 'fakeFingerprint', fingerprint: 'fakeFingerprint',
}); });
assert.equal(await pdfMetadata.getUri(), 'http://fake.com/'); assert.equal(await uriPromise, 'http://fake.com/');
});
}); });
}
);
// The `initializedPromise` param simulates different versions of PDF.js with
// and without the `PDFViewerApplication.initializedPromise` API.
[true, false].forEach(initializedPromise => {
it('does not wait for the PDF to load if it has already loaded', function () { it('does not wait for the PDF to load if it has already loaded', function () {
const fakePDFViewerApplication = new FakePDFViewerApplication(); const fakePDFViewerApplication = new FakePDFViewerApplication('', {
initializedPromise,
});
fakePDFViewerApplication.completeInit();
fakePDFViewerApplication.finishLoading({ fakePDFViewerApplication.finishLoading({
url: 'http://fake.com', url: 'http://fake.com',
fingerprint: 'fakeFingerprint', fingerprint: 'fakeFingerprint',
...@@ -161,10 +206,12 @@ describe('annotator/plugin/pdf-metadata', function () { ...@@ -161,10 +206,12 @@ describe('annotator/plugin/pdf-metadata', function () {
assert.equal(uri, 'http://fake.com/'); assert.equal(uri, 'http://fake.com/');
}); });
}); });
});
describe('metadata sources', function () { describe('metadata sources', function () {
let pdfMetadata; let pdfMetadata;
const fakePDFViewerApplication = new FakePDFViewerApplication(); const fakePDFViewerApplication = new FakePDFViewerApplication();
fakePDFViewerApplication.completeInit();
fakePDFViewerApplication.finishLoading({ fakePDFViewerApplication.finishLoading({
fingerprint: 'fakeFingerprint', fingerprint: 'fakeFingerprint',
title: 'fakeTitle', title: 'fakeTitle',
...@@ -187,6 +234,7 @@ describe('annotator/plugin/pdf-metadata', function () { ...@@ -187,6 +234,7 @@ describe('annotator/plugin/pdf-metadata', function () {
it('returns the fingerprint as a URN when the PDF URL is a local file', function () { it('returns the fingerprint as a URN when the PDF URL is a local file', function () {
const fakePDFViewerApplication = new FakePDFViewerApplication(); const fakePDFViewerApplication = new FakePDFViewerApplication();
fakePDFViewerApplication.completeInit();
fakePDFViewerApplication.finishLoading({ fakePDFViewerApplication.finishLoading({
url: 'file:///test.pdf', url: 'file:///test.pdf',
fingerprint: 'fakeFingerprint', fingerprint: 'fakeFingerprint',
...@@ -200,6 +248,7 @@ describe('annotator/plugin/pdf-metadata', function () { ...@@ -200,6 +248,7 @@ describe('annotator/plugin/pdf-metadata', function () {
it('resolves relative URLs', () => { it('resolves relative URLs', () => {
const fakePDFViewerApplication = new FakePDFViewerApplication(); const fakePDFViewerApplication = new FakePDFViewerApplication();
fakePDFViewerApplication.completeInit();
fakePDFViewerApplication.finishLoading({ fakePDFViewerApplication.finishLoading({
url: 'index.php?action=download&file_id=wibble', url: 'index.php?action=download&file_id=wibble',
fingerprint: 'fakeFingerprint', fingerprint: 'fakeFingerprint',
...@@ -258,6 +307,7 @@ describe('annotator/plugin/pdf-metadata', function () { ...@@ -258,6 +307,7 @@ describe('annotator/plugin/pdf-metadata', function () {
it('does not save file:// URLs in document metadata', function () { it('does not save file:// URLs in document metadata', function () {
let pdfMetadata; let pdfMetadata;
const fakePDFViewerApplication = new FakePDFViewerApplication(); const fakePDFViewerApplication = new FakePDFViewerApplication();
fakePDFViewerApplication.completeInit();
fakePDFViewerApplication.finishLoading({ fakePDFViewerApplication.finishLoading({
fingerprint: 'fakeFingerprint', fingerprint: 'fakeFingerprint',
url: 'file://fake.pdf', url: 'file://fake.pdf',
......
...@@ -62,6 +62,8 @@ ...@@ -62,6 +62,8 @@
* Defined in `web/pdf_viewer.js` in the PDF.js source. * Defined in `web/pdf_viewer.js` in the PDF.js source.
* *
* @prop {number} pagesCount * @prop {number} pagesCount
* @prop {EventBus} eventBus -
* Reference to the global event bus. Added in PDF.js v1.6.210.
* @prop {(page: number) => PDFPageView|null} getPageView * @prop {(page: number) => PDFPageView|null} getPageView
*/ */
...@@ -80,12 +82,14 @@ ...@@ -80,12 +82,14 @@
* *
* @typedef PDFViewerApplication * @typedef PDFViewerApplication
* @prop {EventBus} [eventBus] - * @prop {EventBus} [eventBus] -
* Global event bus. Since v1.6.210. * Global event bus. Since v1.6.210. This is not available until the PDF viewer
* has been initialized. See `initialized` and `initializedPromise` properties.
* @prop {PDFDocument} pdfDocument * @prop {PDFDocument} pdfDocument
* @prop {PDFViewer} pdfViewer * @prop {PDFViewer} pdfViewer
* @prop {boolean} downloadComplete * @prop {boolean} downloadComplete
* @prop {PDFDocumentInfo} documentInfo * @prop {PDFDocumentInfo} documentInfo
* @prop {Metadata} metadata * @prop {Metadata} metadata
* @prop {boolean} initialized - Indicates that the PDF viewer is initialized.
* @prop {Promise<void>} [initializedPromise] - * @prop {Promise<void>} [initializedPromise] -
* Promise that resolves when PDF.js is initialized. Since v2.4.456. * Promise that resolves when PDF.js is initialized. Since v2.4.456.
* See https://github.com/mozilla/pdf.js/wiki/Third-party-viewer-usage#initialization-promise. * See https://github.com/mozilla/pdf.js/wiki/Third-party-viewer-usage#initialization-promise.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment