DEV Community

Cover image for PDF.js Deep Dive from Demo 3: getPage
StreamPDF
StreamPDF

Posted on Originally published at streampdf.com

PDF.js Deep Dive from Demo 3: getPage

Introduction

In the demo of the first article, we obtained the pdf variable via await loadingTask.promise — that promise is resolved by the main thread's GetDoc handler after the worker sends back the GetDoc message; see article 13 for details — and it is an instance of PDFDocumentProxy.

Back to the demo in the first article, lines 8-9:

const page = await pdf.getPage(1);
Enter fullscreen mode Exit fullscreen mode

The getPage call

This call passes a single numeric argument:

Parameter Value Meaning
pageNumber 1 Page number; the first page is 1 (1-based)

getPage returns a Promise that resolves to a PDFPageProxy object. This article follows that single call through the full execution chain of getPage.

According to the definition in api.js, the execution flow is as follows.

Lines 773-775: PDFDocumentProxy.getPage: a one-line delegation

getPage(pageNumber) {
  return this._transport.getPage(pageNumber);
}
Enter fullscreen mode Exit fullscreen mode

The pdf object itself is just a facade; the real logic lives entirely on this._transport — the WorkerTransport instance created in article 2 (task._transport).

Everything below happens inside WorkerTransport.getPage.

Lines 3031-3037: Validate the arguments

if (!Number.isInteger(pageNumber) || pageNumber <= 0 || pageNumber > this.pagesMapper.pagesNumber) {
  return Promise.reject(new Error('Invalid page request.'));
}
Enter fullscreen mode Exit fullscreen mode

Plugging the demo values into each condition:

Condition With pageNumber = 1 Result
!Number.isInteger(pageNumber) Number.isInteger(1) is true, so the negation is false Not hit
pageNumber <= 0 1 <= 0 is false Not hit
pageNumber > this.pagesMapper.pagesNumber pagesNumber is 756, 1 > 756 is false Not hit

pagesNumber is 756, written from pdfInfo.numPages when the document becomes ready: view source. The demo PDF in this article has 756 pages, so validation passes. If you pass 0, a negative number, a decimal, or a number beyond the page count, this returns a rejected promise immediately.

Q: Why use Promise.reject instead of throw?
A: To keep the asynchronous semantics consistent. Callers of getPage always await a promise, so argument errors go through the same error channel. Callers don't have to handle both a synchronous throw and an asynchronous rejection.

Lines 3038-3039: Convert the page number

const pageIndex = pageNumber - 1; // 1 - 1 = 0
const newPageIndex = this.pagesMapper.getPageId(pageNumber) - 1; // 1 - 1 = 0
Enter fullscreen mode Exit fullscreen mode

These two lines produce three page numbering schemes, each serving a different consumer:

Variable Perspective Base Value here Purpose
pageNumber User 1-based 1 The public getPage argument
pageIndex Main thread 0-based 0 Key of #pageCache / #pagePromises
newPageIndex Worker (original document) 0-based 0 Payload of the GetPage message

pagesMapper.getPageId(1) returns 1. The demo never moves, deletes, or copies pages, so the mapping is an identity mapping (#pageNumberToId is null), and therefore newPageIndex === pageIndex === 0. The three only diverge once the document has been edited.

PagesMapper: translating between user page numbers and original page order

PagesMapper was created with new PagesMapper() during argument parsing in article 2: view source. It exposes just two things:

  • pagesNumber: the current total page count. Once assigned, the internal #pageNumberToId / #prevPageNumbers are reset to null, falling back to the identity mapping;
  • getPageId(pageNumber) / getPageNumber(pageId): two-way translation (L462-L464, L443-L454). Under the identity mapping they return their input unchanged; only an actual page reorder builds a real table.

Q: Why go through PagesMapper at all?
A: To support page reordering. In an editor scenario, users can move, delete, and copy pages, after which "page 3" may no longer be the original document's page 3. The worker side only understands the original document's page order, so what gets sent to the worker is newPageIndex, while the two main-thread caches are keyed by the user-facing pageIndex. Under an identity mapping the two are identical and can be ignored. Note: PagesMapper has nothing to do with the PDF file itself — the PDF contains no such data; it exists purely to hold state while editing.

One sentence to distinguish them: pageIndex is the cache key on the main thread, while newPageIndex is the key sent to the worker.

Lines 3041-3044: Check the cache

const cachedPromise = this.#pagePromises.get(0); // pageIndex = 0
if (cachedPromise) {
  return cachedPromise;
}
Enter fullscreen mode Exit fullscreen mode

#pagePromises is a Map<pageIndex, Promise<PDFPageProxy>>. On the first call there is no cache hit, so we continue.

Q: What does the cache actually do?
A: In practice, calling await pdf.getPage(1) twice in a row returns the same PDFPageProxy object (page === page2 is true). The second call hits the cached promise directly and no message is sent to the worker.

Three layers of cache: separate stores, separate jobs

WorkerTransport declares all three Maps as class fields in one place: view source

#pageCache = new Map();
#pagePromises = new Map();
#pageRefCache = new Map();
Enter fullscreen mode Exit fullscreen mode
Field Key → Value Responsibility Typical use
#pageCache pageIndex → PDFPageProxy Index of created page objects StartRenderPage looks up a page by index (api.js#L2805); startCleanup iterates it (api.js#L3227)
#pagePromises pageIndex → Promise<PDFPageProxy> Deduplicates in-flight requests and repeat calls Read at the top of getPage (line 3041 here), written at the end (line 3067)
#pageRefCache refStr → pageIndex Reverse lookup from object reference to page index Written by getPage (line 3054), read by cachedPageNumber (api.js#L3245-L3255)

All three share the same lifetime and are cleared when the transport is destroyed: destroy() clears them in turn (api.js#L2611-L2616).

Q: Why keep #pagePromises when #pageCache already holds the page object?
A: They solve different problems. #pagePromises is written before the message even leaves, so that "several getPage calls arriving at the same time only send one GetPage message". #pageCache is only written after the reply arrives, so that transport-internal events can look up objects by page index. Caching the promise rather than the page is precisely what covers the window before the reply arrives.

Lines 3045-3048: Send the GetPage message to the worker

const promise = this.messageHandler
  .sendWithPromise('GetPage', {
    pageIndex: newPageIndex, // 0
  })
  .then(pageInfo => {
    // The reply handling is covered in the next section
  });
Enter fullscreen mode Exit fullscreen mode

messageHandler is the MessageHandler created in article 2. The communication mechanism of sendWithPromise is covered in article 6. All you need to know here is that it sends the message to the worker thread and returns a promise that waits for the worker's reply.

When the worker receives GetPage: view source

handler.on('GetPage', async function ({ pageIndex }) {
  const page = await pdfManager.getPage(pageIndex);
  const [rotate, ref, userUnit, view] = await Promise.all([
    pdfManager.ensure(page, 'rotate'),
    pdfManager.ensure(page, 'ref'),
    pdfManager.ensure(page, 'userUnit'),
    pdfManager.ensure(page, 'view'),
  ]);
  return {
    rotate,
    ref,
    refStr: ref?.toString() ?? null,
    userUnit,
    view,
  };
});
Enter fullscreen mode Exit fullscreen mode

How the worker parses the page object internally is covered in article 14. The reply for the demo's first page is:

pageInfo = {
  rotate: 0,
  ref: { num: 4, gen: 0 }, // after structured cloning across threads, the Ref instance degrades to a plain object
  refStr: '4R', // the result of ref.toString() on the worker side
  userUnit: 1,
  view: [0, 0, 595, 842],
};
Enter fullscreen mode Exit fullscreen mode
Field Value here Meaning Matching PDFPageProxy getter
rotate 0 Clockwise rotation in degrees rotate
ref { num: 4, gen: 0 } Object reference (the entry Ref of this page in the PDF file) ref
refStr "4R" String form of the reference, used as a cache key None (internal only)
userUnit 1 User unit conversion factor userUnit
view [0, 0, 595, 842] Visible area (PDF user space) view

The 595 × 842 of view is exactly A4 paper size in 1/72-inch units, matching the demo's PDF specification.

Q: What is ref: { num: 4, gen: 0 }? Why isn't it a Ref instance?

A: It is PDF's Indirect Reference — every object in a PDF file has a unique (num, gen) number, and the page object is no exception. num = 4 means "object #4", gen = 0 is the generation number (almost always 0 in new PDFs; it only bumps up after incremental updates).

On the worker side this was a real Ref instance: view source created via Ref.get(4, 0), with a cached private #str = '4R'. But it cannot cross threads: Web Worker postMessage uses Structured Clone, which only copies enumerable own properties. Ref's #str is a private field — it doesn't clone — and class instances degrade to plain objects, leaving only the two public fields num and gen. That's why the main thread receives { num: 4, gen: 0 }.

The main-thread PDFPageProxy.ref getter (api.js#L1399) simply returns this plain object — it never reconstructs a Ref, because the main thread only ever needs to read num / gen and never sends them back as-is. Later messages like GetPageIndex or the editor's SaveDocument rebuild a Ref on the worker side from these two fields.

Why pre-compute refStr on the worker side? Precisely because Ref#toString() is lost after crossing threads — the main thread holds only a plain object and can't call toString. So the worker pre-computes refStr: ref?.toString() ?? null in its return value. Two fields, two roles: ref for callers that need to split num / gen, refStr for cache lookups that need a string key.

Q: Why is refStr "4R" rather than "4 0 R"?

A: Ref#toString returns the compact string cached inside the object: numR when gen is zero, numRgen otherwise: view source. The reverse-lookup side, cachedPageNumber, builds its key as ${ref.num}R as well, so both sides use the same format.

Q: Why only these 4 properties?

A: They are all metadata of the page: rotation angle, object reference, unit conversion factor, and visible area. Reading them only requires parsing the page object dictionary, without touching the page content stream. The real content parsing (drawing commands) is deferred to the GetOperatorList message at render time. This is why getPage can return quickly.

These 4 properties map one-to-one to getters on PDFPageProxy: rotate, ref, userUnit, and view. The next article's getViewport will use two of them.

Lines 3049-3068: Handle the reply and create the PDFPageProxy

Once the reply arrives, we enter the .then callback:

.then(pageInfo => {
  if (this.destroyed) {
    throw new Error("Transport destroyed");
  }
  if (pageInfo.refStr) {
    this.#pageRefCache.set(pageInfo.refStr, newPageIndex); // "4R" -> 0
  }

  const page = new PDFPageProxy(
    pageIndex,          // 0
    pageInfo,
    this,
    this.pagesMapper,
    this._params.pdfBug // false
  );
  this.#pageCache.set(pageIndex, page);
  return page;
});
this.#pagePromises.set(pageIndex, promise); // line 3067
return promise;                             // line 3068
Enter fullscreen mode Exit fullscreen mode

Four things happen in sequence:

  1. destroyed check: while waiting for the reply, the document may already have been destroyed (loadingTask.destroy()); if so, throw an error;
  2. #pageRefCache reverse-lookup cache: "4R" → 0. Used by cachedPageNumber(ref) for reverse lookups — for example, when an outline link jumps to a page, the PDF internally locates pages by object reference (4 0 R), which must be translated back to a page number;
  3. Create the PDFPageProxy and store it in #pageCache;
  4. Store the promise itself in #pagePromises.

Q: Why does #pageRefCache store newPageIndex rather than pageIndex?

A: Because it records "which page of the original document this object reference points to", which has nothing to do with the user's perspective. During reverse lookup, cachedPageNumber first obtains the original page index, then translates it back to the user page number via pagesMapper.getPageNumber(pageIndex + 1) (api.js#L3245-L3255). Under an identity mapping the two happen to be equal, but semantically they are not interchangeable.

The PDFPageProxy constructor: assignment only, no parsing

View source

constructor(pageIndex, pageInfo, transport, pagesMapper, pdfBug = false) {
  this._pageIndex = pageIndex;                  // 0
  this._pageInfo = pageInfo;                    // the pageInfo received above
  this._transport = transport;
  this._stats = pdfBug ? new StatTimer() : null; // false, so performance stats are not enabled
  this._pdfBug = pdfBug;
  this.commonObjs = transport.commonObjs;       // resource container shared across the whole document (fonts, etc.)
  this.objs = new PDFObjects();                 // resource container private to this page
  this._intentStates = new Map();               // rendering intent state cache, used by render in article 5
  this.destroyed = false;
  this.recordedBBoxes = null;                   // recorded regions for the editor scenario
  this.#pagesMapper = pagesMapper;
  this.imageCoordinates = null;                 // image coordinate mapping, only populated after rendering
}
Enter fullscreen mode Exit fullscreen mode

The whole constructor contains not a single await and does no parsing at all; it only moves references around and seeds placeholders:

  • _pageIndex is exposed to the outside as pageNumber via a getter returning _pageIndex + 1;
  • _pageInfo is the pageInfo above; all four getters read from it;
  • commonObjs and objs are resource containers used later during rendering; PDFObjects will be covered in a separate article;
  • recordedBBoxes / imageCoordinates stay null until the first render.

This is also why the main-thread cost of getPage is so small: the heavy lifting happens entirely on the worker thread, and the main thread only assembles the object.

At this point, the promise returned by getPage(1) resolves to page, and the demo's await receives it.

Summary

In this section we followed the demo through the complete getPage(1) call:

  1. PDFDocumentProxy.getPage delegates in one line to WorkerTransport.getPage;
  2. Argument validation: 1 is a valid page number (756 pages in total);
  3. Page number conversion: pageNumber(1) → pageIndex(0) → newPageIndex(0); under the identity mapping all three agree;
  4. The #pagePromises cache is checked and missed on the first call;
  5. A GetPage message is sent to the worker, and rotate / ref / refStr / userUnit / view come back;
  6. #pageRefCache is written, the PDFPageProxy is created, and #pageCache is populated;
  7. The promise is stored in #pagePromises and returned; the demo's await yields page.

With page in hand, the demo's next step is page.getViewport({ scale: 0.5 }) — which will use the view and userUnit from this article's pageInfo. That will be covered in the next article.


About me: Creator of StreamPDF, focused on research and development in AI and PDF.
Original article: https://streampdf.com/blog/pdfjs-deep-dive-from-demo-3-get-page

Top comments (0)