PDF.js Deep Dive from Demo 2: getDocument
Introduction
The demo in the previous article called getDocument with only { url: '...' }. This article follows that single call through the full execution chain of getDocument.
The getDocument call
The source parameter is named src. The demo passes only one key:
| Key in src | Value |
| ------------ | -------------------------------------------------- |
| url | 'https://assets-blog.streampdf.com/PDF32000_2008.pdf' |
Based on the definition of getDocument in display/api.js, the execution flow is as follows.
Lines 235-236: Initialize the loading task
Instantiate the PDFDocumentLoadingTask class: view source, and assign the return value to the task variable.
Extract docId from task; at this point docId is d0.
Lines 238-335: Validate and process parameters
This block of code expands the user-provided src object into 35+ local variables: provided values are validated and type-converted, while missing ones fall back to defaults. The demo only passes url, so the vast majority of parameters take the default branch.
The only provided url is converted into a URL object by getUrlProp:
const url = URL.parse('https://assets-blog.streampdf.com/PDF32000_2008.pdf', window.location);
// const url = src.url ? getUrlProp(src.url) : null;
All other parameters are absent. In source order, the computed results are:
| Variable | Result | Logic |
| ---------------------------- | ------------------------ | ------------------------------------------------------------------------ |
| data | null | If provided, normalized to Uint8Array by getDataProp |
| httpHeaders | null | Passed through as-is; null when absent |
| withCredentials | false | Only accepts === true |
| password | null | ?? null |
| rangeTransport | null | Only accepts a PDFDataRangeTransport instance |
| rangeChunkSize | 2 ** 16 (64KB) | Only accepts the provided value if it is a positive integer |
| worker | null | Only accepts a PDFWorker instance |
| verbosity | undefined | Taken verbatim from src.verbosity |
| docBaseUrl | null | Only accepts a string that is not a data: URL |
| cMapUrl | null | getFactoryUrlProp: non-string becomes null, and it must end with / |
| cMapPacked | true | !== false |
| iccUrl | null | Same as cMapUrl |
| standardFontDataUrl | null | Same as cMapUrl |
| wasmUrl | null | Same as cMapUrl |
| ignoreErrors | true | src.stopAtErrors !== true, see below |
| maxImageSize | -1 (unlimited) | Only accepted if an integer and > -1 |
| isOffscreenCanvasSupported | true | Defaults to !isNodeJS when absent |
| isImageDecoderSupported | true | Defaults to !isNodeJS when absent |
| canvasMaxAreaInBytes | -1 (unlimited) | Only accepted if an integer |
| disableFontFace | false | Defaults to isNodeJS when absent |
| fontExtraProperties | false | Only accepts === true |
| enableXfa | false | Only accepts === true |
| ownerDocument | globalThis.document | Defaults to the global document when absent |
| disableRange | false | Only accepts === true |
| disableStream | false | Only accepts === true |
| disableAutoFetch | false | Only accepts === true |
| pdfBug | false | Only accepts === true |
| CanvasFactory | DOMCanvasFactory | Environment branch, see below |
| FilterFactory | DOMFilterFactory | Same as above |
| BinaryDataFactory | DOMBinaryDataFactory | Same as above |
| enableHWA | false | Only accepts === true |
| enableWebGPU | false | Only accepts === true |
| gpuPromise | Promise.resolve(false) | WebGPU is off, so GPU initialization does not start |
| useWasm | true | !== false |
| pagesMapper | new PagesMapper() | Created fresh when absent |
| useSystemFonts | true | Cascading default, see below |
| useWorkerFetch | false | Compound check, see the Q&A below |
| styleElement | null | Only preserved in TESTING builds; always null in production |
These are all the local variables produced by this block of code. Most of the checks are trivial "fall back to default when absent" cases, but a few deserve a closer look.
ignoreErrors: a semantically inverted internal variable
The user-facing parameter is called stopAtErrors, yet the internal variable is called ignoreErrors, computed as src.stopAtErrors !== true. In other words, the default behavior is to ignore errors: when parsing fails, try to recover rather than rejecting promises such as getOperatorList / getTextContent / RenderTask. When reading the source, it is very easy to copy the name and value the wrong way around here.
rangeChunkSize: the 64KB setup for later
rangeChunkSize defaults to 64KB. Since disableRange = false, when the transport is created later, range requests will be issued against the PDF, and each fetched chunk will be exactly this size. Keep it in mind for now.
docBaseUrl: why data: URLs are excluded
The source comment explains the reason: a data: URL cannot be used to resolve absolute paths, and it contains the entire PDF document with no upper bound on length, so it is not sent to the worker thread.
gpuPromise: designed to run in parallel with worker startup
const gpuPromise = Promise.resolve(false); // enableWebGPU ? initGPU() : Promise.resolve(false);
Even when WebGPU is disabled, this immediately returns a resolved promise instead of deferring the check until it is used. The comment explains the intent: let GPU initialization run in parallel with worker startup, with the resolved boolean forwarded to the worker afterwards.
The three Factories: environment branches
const CanvasFactory = DOMCanvasFactory;
// src.CanvasFactory || (GENERIC build && isNodeJS ? NodeCanvasFactory : DOMCanvasFactory);
The same applies to FilterFactory and BinaryDataFactory. The check has three tiers: user-provided custom > Node environment implementation > DOM implementation. The demo runs in a browser without customization, so all three factories are the DOM versions. Their responsibilities:
- CanvasFactory: creates canvases for rendering
- FilterFactory: creates image filters (such as SVG filters)
- BinaryDataFactory: downloads built-in static assets; works together with useWorkerFetch below
useSystemFonts: the default depends on other parameters
const useSystemFonts = true;
// typeof src.useSystemFonts === "boolean" ? src.useSystemFonts : !isNodeJS && !disableFontFace;
Its default depends on disableFontFace, which was just parsed above. Why? Fonts not embedded in the PDF are loaded as FontFaces; if disableFontFace is true, that loading path is disabled, and falling back to system fonts at that point would produce completely garbled glyphs. So in that scenario the default is simply false as well, avoiding rendering utterly broken fonts.
useWorkerFetch: a compound check
const useWorkerFetch = false;
// typeof src.useWorkerFetch === "boolean"
// ? src.useWorkerFetch
// : MOZCENTRAL ||
// !!(
// BinaryDataFactory === DOMBinaryDataFactory &&
// cMapUrl && cMapPacked && standardFontDataUrl && wasmUrl &&
// isValidFetchUrl(cMapUrl, document.baseURI) &&
// isValidFetchUrl(standardFontDataUrl, document.baseURI) &&
// isValidFetchUrl(wasmUrl, document.baseURI)
// );
> Q: What is this check doing?
> A: It decides who downloads the built-in static assets (CMap files, standard font files, wasm files): the worker thread downloads them itself via the Fetch API (true), or the main thread downloads them and forwards them to the worker (false).
>
> If the user explicitly passes a boolean, the user's value is used directly. Otherwise the default is computed: MOZCENTRAL (the Firefox built-in viewer) is always true; for a regular build, all three of the following conditions must hold at the same time for it to be true:
>
> 1. No custom BinaryDataFactory — because when useWorkerFetch is true, that parameter is simply ignored (inside transportFactory, binaryDataFactory is set to null). A user-provided factory must be respected, so the default cannot be true;
> 2. cMapUrl / standardFontDataUrl / wasmUrl are all provided, and cMapPacked is true — without URLs there is nothing to fetch;
> 3. These three URLs resolve to http/https relative to document.baseURI — the Fetch API only supports http/https, not file/ftp.
>
> The demo in this article only passes url, so cMapUrl is null, condition 2 is not met, and therefore useWorkerFetch = false: the main-thread path is taken, with DOMBinaryDataFactory downloading on the main thread, and the worker requesting data from the main thread via FetchBinaryData messages when needed.
Lines 338: Set the main-thread log level
setVerbosityLevel(verbosity); // verbosity is undefined
verbosity is undefined, so the main-thread log level stays at its default. Note that this only affects the main thread; the worker thread's log level is passed separately when the worker is created.
Lines 342-350: Instantiate the three factories
const transportFactory = {
canvasFactory: new DOMCanvasFactory({ ownerDocument: document, enableHWA: false }),
filterFactory: new DOMFilterFactory({ docId: 'd0', ownerDocument: document }),
binaryDataFactory: new DOMBinaryDataFactory({
cMapUrl: null,
standardFontDataUrl: null,
wasmUrl: null,
}),
};
// binaryDataFactory: null when MOZCENTRAL or useWorkerFetch is true
The three Factory classes selected earlier are instantiated and packaged as transportFactory, which is then handed to the transport. Since useWorkerFetch = false, binaryDataFactory is not null — echoing the Q&A in the previous section: built-in static assets are downloaded on the main thread.
Instantiate DOMCanvasFactory: view source
Instantiate DOMFilterFactory: view source
Instantiate DOMBinaryDataFactory: view source
Lines 352-360: Create the worker
worker = PDFWorker.create({ verbosity: undefined, port: undefined });
// port comes from GlobalWorkerOptions.workerPort, which the demo does not set
task._worker = worker;
The demo does not pass a worker, so PDF.js creates and owns one itself. PDFWorker.create synchronously returns a PDFWorker instance; its constructor reads GlobalWorkerOptions.workerSrc (the worker file URL configured in the first article), creates a Worker, and completes the startup handshake through ready / test messages — covered in detail in Article 8. For now, just remember: worker.promise is a Promise<void> that resolves (to undefined) once the test reply succeeds, and rejects if startup fails or the worker is destroyed.
Lines 362-402: Assemble the two parameter packages
The first is docParams, which will later be sent across threads to the worker:
const docParams = {
docId: 'd0',
apiVersion: '6.3.289', // build version number
data: null,
password: null,
disableAutoFetch: false,
rangeChunkSize: 65536,
docBaseUrl: null,
enableXfa: false,
evaluatorOptions: {
maxImageSize: -1,
disableFontFace: false,
ignoreErrors: true,
isOffscreenCanvasSupported: true,
isImageDecoderSupported: true,
canvasMaxAreaInBytes: -1,
fontExtraProperties: false,
useSystemFonts: true,
useWasm: true,
useWorkerFetch: false,
cMapUrl: null,
cMapPacked: true,
iccUrl: null,
standardFontDataUrl: null,
wasmUrl: null,
hasGPU: false, // placeholder, backfilled later
},
};
The second is transportParams, which stays on the main thread:
const transportParams = {
ownerDocument: document,
pdfBug: false,
styleElement: null,
enableHWA: false,
loadingParams: { disableAutoFetch: false, enableXfa: false },
};
One sentence to distinguish them: docParams is the "work order" sent to the worker, while transportParams is the "local configuration" kept on the main thread.
Lines 404-473: The handshake after the worker is ready
Promise.all([worker.promise, gpuPromise]).then(...)
Once both worker readiness and GPU probing (resolving to false) complete, the following happens in sequence:
1. hasGPU = false is backfilled into evaluatorOptions;
2. A GetDocRequest message is sent to the worker with docParams as the payload. Since data is null, there is no buffer whose ownership needs to be transferred;
3. Choose the network stream: with data being null and rangeTransport being null, the url branch is taken and a network stream is instantiated (the fetch implementation is preferred, with a fallback to XHR when unsupported; see Article 9 for how range requests are issued internally):
networkStream = new NetworkStream({
url,
httpHeaders: null,
withCredentials: false,
rangeChunkSize: 65536,
disableRange: false,
disableStream: false,
});
NetworkStream here is not a fixed imported class; it is picked at runtime inside this branch:
const NetworkStream = getNetworkStream(url); // api.js#L431
getNetworkStream makes a three-way choice:
function getNetworkStream(url) {
// eslint-disable-next-line no-nested-ternary
return isValidFetchUrl(url)
? PDFFetchStream
: typeof PDFJSDev !== 'undefined' && PDFJSDev.test('GENERIC') && isNodeJS
? PDFNodeStream
: PDFNetworkStream;
}
| Condition | Stream implementation |
| --- | --- |
| isValidFetchUrl(url): protocol is http / https | PDFFetchStream |
| Otherwise, GENERIC build running in Node | PDFNodeStream |
| Everything else | PDFNetworkStream (XHR) |
The demo's url is https://..., so it actually instantiates PDFFetchStream. The other inputs have their own streams too: rangeTransport uses PDFDataTransportStream, while data means the whole PDF is already in memory and no stream is created at all.
In summary: const NetworkStream = PDFFetchStream.
Execution continues:
1. Receive the worker's acknowledgment of GetDocRequest (workerId): click to view the event. The value returned here is d0_worker, assigned to the workerId variable
2. Create a MessageHandler with { docId: 'd0', workerId: 'd0_worker', port: Worker }: click to view the call, assigned to the messageHandler variable
3. Create a WorkerTransport with { messageHandler, task, networkStream, transportParams, transportFactory, pagesMapper }: click to view the call, attached to task._transport
4. Send a Ready message to the worker, declaring the main-thread side ready: click to view the event
At the end of the chain, task._setupCapability.resolve is called to settle the task's asynchronous setup.
Lines 474-475: Return value
All of the above .then chain proceeds asynchronously in the background, while getDocument itself returns task synchronously. If any step in the chain fails, the capability inside task is rejected — the very promise that the demo waits on with await loadingTask.promise.
At this point, getDocument has finished executing.
Summary
In this section we followed the demo through the complete getDocument call:
1. Create PDFDocumentLoadingTask and extract docId = 'd0';
2. Expand src into 35+ local variables; the demo only passes url, and everything else falls back to defaults;
3. Instantiate the three DOM factories and package them as transportFactory;
4. PDFWorker.create creates the worker and starts the handshake;
5. Assemble docParams (sent to the worker) and transportParams (kept on the main thread);
6. Once the worker is ready, send GetDocRequest, set up the network stream, create MessageHandler and WorkerTransport, and finally send Ready;
7. Return task synchronously; the demo waits for the document to be ready via await loadingTask.promise.
Back to the demo in the first article: lines 4-7 call exactly the getDocument dissected here.
Following the caller chain upward, the next call site is: getPage
---
About me: Creator of StreamPDF, focused on research and development in AI and PDF.
Original article: streampdf.com
PDF Reader in JavaScript
2 points
such a great tool
mozilla/pdf.js
2 likes · 37 views
PDF.js Deep Dive 3: getViewport & render
Introduction
This is the third article in the PDF.js Deep Dive series. In the previous article, we explored the Display layer's getDocument and getPage methods.
In this article, we dive into the getViewport and render methods exposed by the Display layer.
All code snippets in this article are based on PDF.js v6.3.289.
- PDF.js Deep Dive 1: The Architecture of PDF.js
- PDF.js Deep Dive 2: getDocument & getPage
getViewport
For page.getViewport, the call lands on the PDFPageProxy.getViewport method.
/**
* @param {GetViewportParameters} params - Viewport parameters.
* @returns {PageViewport} Contains 'width' and 'height' properties
* along with transforms required for rendering.
*/
getViewport({
scale,
rotation = this.rotate,
offsetX = 0,
offsetY = 0,
dontFlip = false,
} = {}) {
return new PageViewport({
viewBox: this.view,
userUnit: this.userUnit,
scale,
rotation,
offsetX,
offsetY,
dontFlip,
});
}
which instantiates the PageViewport class.
Entering PageViewport
// Source: src/display/page_viewport.js
/**
* @param {PageViewportParameters}
*/
constructor({
viewBox,
userUnit,
scale,
rotation,
offsetX = 0,
offsetY = 0,
dontFlip = false,
}) {
this.viewBox = viewBox;
this.userUnit = userUnit;
this.scale = scale;
this.rotation = rotation;
this.offsetX = offsetX;
this.offsetY = offsetY;
scale *= userUnit; // Take the userUnit into account.
// creating transform to convert pdf coordinate system to the normal
// canvas like coordinates taking in account scale and rotation
const centerX = (viewBox[2] + viewBox[0]) / 2;
const centerY = (viewBox[3] + viewBox[1]) / 2;
let rotateA, rotateB, rotateC, rotateD;
// Normalize the rotation, by clamping it to the [0, 360) range.
rotation %= 360;
if (rotation < 0) {
rotation += 360;
}
switch (rotation) {
case 180:
rotateA = -1;
rotateB = 0;
rotateC = 0;
rotateD = 1;
break;
case 90:
rotateA = 0;
rotateB = 1;
rotateC = 1;
rotateD = 0;
break;
case 270:
rotateA = 0;
rotateB = -1;
rotateC = -1;
rotateD = 0;
break;
case 0:
rotateA = 1;
rotateB = 0;
rotateC = 0;
rotateD = -1;
break;
default:
throw new Error(
"PageViewport: Invalid rotation, must be a multiple of 90 degrees."
);
}
if (dontFlip) {
rotateC = -rotateC;
rotateD = -rotateD;
}
let offsetCanvasX, offsetCanvasY;
let width, height;
if (rotateA === 0) {
offsetCanvasX = Math.abs(centerY - viewBox[1]) * scale + offsetX;
offsetCanvasY = Math.abs(centerX - viewBox[0]) * scale + offsetY;
width = (viewBox[3] - viewBox[1]) * scale;
height = (viewBox[2] - viewBox[0]) * scale;
} else {
offsetCanvasX = Math.abs(centerX - viewBox[0]) * scale + offsetX;
offsetCanvasY = Math.abs(centerY - viewBox[1]) * scale + offsetY;
width = (viewBox[2] - viewBox[0]) * scale;
height = (viewBox[3] - viewBox[1]) * scale;
}
// creating transform for the following operations:
// translate(-centerX, -centerY), rotate and flip vertically,
// scale, and translate(offsetCanvasX, offsetCanvasY)
this.transform = [
rotateA * scale,
rotateB * scale,
rotateC * scale,
rotateD * scale,
offsetCanvasX - rotateA * scale * centerX - rotateC * scale * centerY,
offsetCanvasY - rotateB * scale * centerX - rotateD * scale * centerY,
];
this.width = width;
this.height = height;
}
It computes the rotation, the scale, the offsets, and the viewport dimensions after flipping — the page's width and height.
Let's zoom in on the PDF point and the CSS px units. In PDF, 72 points equal one inch; in CSS, 96 px equal one inch. That means rendering at 100% size on the web requires scaling PDF points up by 96/72.
PDF's coordinate system has its origin at the bottom-left corner, with the X axis pointing right and the Y axis pointing up. CSS's coordinate system has its origin at the top-left corner, with the X axis pointing right and the Y axis pointing down. So for an unrotated PDF, we need to flip the PDF's Y axis to match the web canvas coordinate system. That's why you see
case 0:
rotateA = 1;
rotateB = 0;
rotateC = 0;
rotateD = -1;
the rotateD of -1 in there — it exists precisely to flip the Y axis.
Flip demo: get-viewport-demo
MDN canvas transform
render
For page.render, the call lands on the PDFPageProxy.render method.
/**
* Begins the process of rendering a page to the desired context.
*
* @param {RenderParameters} params - Page render parameters.
* @returns {RenderTask} An object that contains a promise that is
* resolved when the page finishes rendering.
*/
render({
canvasContext,
canvas = canvasContext.canvas,
viewport,
intent = "display",
annotationMode = AnnotationMode.ENABLE,
transform = null,
background = null,
optionalContentConfigPromise = null,
annotationCanvasMap = null,
pageColors = null,
printAnnotationStorage = null,
isEditing = false,
recordImages = false,
recordOperations = false,
operationsFilter = null,
}) {
this._stats?.time("Overall");
const intentArgs = this._transport.getRenderingIntent(
intent,
annotationMode,
printAnnotationStorage,
isEditing
);
const { renderingIntent, cacheKey } = intentArgs;
// If there was a pending destroy, cancel it so no cleanup happens during
// this call to render.
this.#pendingCleanup = false;
optionalContentConfigPromise ||=
this._transport.getOptionalContentConfig(renderingIntent);
const intentState = this._intentStates.getOrInsertComputed(
cacheKey,
makeObj
);
// Ensure that a pending `streamReader` cancel timeout is always aborted.
if (intentState.streamReaderCancelTimeout) {
clearTimeout(intentState.streamReaderCancelTimeout);
intentState.streamReaderCancelTimeout = null;
}
const intentPrint = !!(renderingIntent & RenderingIntentFlag.PRINT);
// If there's no displayReadyCapability yet, then the operatorList
// was never requested before. Make the request and create the promise.
if (!intentState.displayReadyCapability) {
intentState.displayReadyCapability = Promise.withResolvers();
intentState.operatorList = {
fnArray: [],
argsArray: [],
lastChunk: false,
separateAnnots: null,
};
this._stats?.time("Page Request");
this._pumpOperatorList(intentArgs);
}
const recordForDebugger = !!(
this._pdfBug && globalThis.StepperManager?.enabled
);
const shouldRecordOperations =
!!canvas &&
!this.recordedBBoxes &&
(recordOperations || recordForDebugger);
const shouldRecordImages =
!!canvas && !this.imageCoordinates && recordImages;
const complete = error => {
intentState.renderTasks.delete(internalRenderTask);
if (shouldRecordOperations) {
const recordedBBoxes = internalRenderTask.gfx?.dependencyTracker.take();
if (recordedBBoxes) {
internalRenderTask.stepper?.setOperatorBBoxes(
recordedBBoxes,
internalRenderTask.gfx.dependencyTracker.takeDebugMetadata()
);
if (recordOperations) {
this.recordedBBoxes = recordedBBoxes;
}
}
}
if (shouldRecordImages && !error) {
this.imageCoordinates = internalRenderTask.gfx?.imagesTracker.take();
}
// Attempt to reduce memory usage during *printing*, by always running
// cleanup immediately once rendering has finished.
if (intentPrint) {
this.#pendingCleanup = true;
}
this.#tryCleanup();
if (error) {
internalRenderTask.capability.reject(error);
this._abortOperatorList({
intentState,
reason: error instanceof Error ? error : new Error(error),
});
} else {
internalRenderTask.capability.resolve();
}
if (this._stats) {
this._stats.timeEnd("Rendering");
this._stats.timeEnd("Overall");
if (globalThis.Stats?.enabled) {
globalThis.Stats.add(this.pageNumber, this._stats);
}
}
};
let dependencyTracker = null;
let bboxTracker = null;
if (shouldRecordOperations || shouldRecordImages) {
bboxTracker = new CanvasBBoxTracker(
canvas,
intentState.operatorList.length
);
}
if (shouldRecordOperations) {
dependencyTracker = new CanvasDependencyTracker(
bboxTracker,
recordForDebugger
);
}
const internalRenderTask = new InternalRenderTask({
callback: complete,
// Only include the required properties, and *not* the entire object.
params: {
canvas,
canvasContext,
dependencyTracker: dependencyTracker ?? bboxTracker,
imagesTracker: shouldRecordImages
? new CanvasImagesTracker(canvas)
: null,
viewport,
transform,
background,
},
objs: this.objs,
commonObjs: this.commonObjs,
annotationCanvasMap,
operatorList: intentState.operatorList,
pageIndex: this._pageIndex,
canvasFactory: this._transport.canvasFactory,
filterFactory: this._transport.filterFactory,
useRequestAnimationFrame: !intentPrint,
pdfBug: this._pdfBug,
pageColors,
enableHWA: this._transport.enableHWA,
operationsFilter,
});
(intentState.renderTasks ||= new Set()).add(internalRenderTask);
const renderTask = internalRenderTask.task;
Promise.all([
intentState.displayReadyCapability.promise,
optionalContentConfigPromise,
])
.then(([transparency, optionalContentConfig]) => {
if (this.destroyed) {
complete();
return;
}
this._stats?.time("Rendering");
if (!(optionalContentConfig.renderingIntent & renderingIntent)) {
throw new Error(
"Must use the same `intent`-argument when calling the `PDFPageProxy.render` " +
"and `PDFDocumentProxy.getOptionalContentConfig` methods."
);
}
internalRenderTask.initializeGraphics({
transparency,
optionalContentConfig,
});
internalRenderTask.operatorListChanged();
})
.catch(complete);
return renderTask;
}
This is a very complex method — let's break it down into chunks.
Step 1: call getRenderingIntent to compute the rendering intent. There are three intents: any, display, and print.
Step 2: call this._intentStates.getOrInsertComputed to get or insert the computed intent state, keyed by the rendering intent from step 1.
Step 3: fetch the page's operator list.
// If there's no displayReadyCapability yet, then the operatorList
// was never requested before. Make the request and create the promise.
if (!intentState.displayReadyCapability) {
intentState.displayReadyCapability = Promise.withResolvers();
intentState.operatorList = {
fnArray: [],
argsArray: [],
lastChunk: false,
separateAnnots: null,
};
this._stats?.time('Page Request');
this._pumpOperatorList(intentArgs);
}
There is a cache layer here: it checks whether intentState.displayReadyCapability exists. If it doesn't, a promise is created and the page's operator list is requested.
> Q: What is intentState.displayReadyCapability? And why must we wait for this promise to resolve before we can actually render anything to the canvas?
>
> A: It is the { promise, resolve, reject } returned by Promise.withResolvers(). Its promise resolves with the boolean transparency (whether the page contains blend modes, which decides whether rendering needs an alpha canvas).
>
> We must wait for it to resolve for two core reasons. First, transparency is a required parameter of initializeGraphics — CanvasGraphics needs to know whether to enable the alpha channel to correctly handle blend modes, and this value can only be determined after the worker has parsed the page's content stream and resources. Second, it is essentially the "page ready" startup signal from the worker: the worker sends the StartRenderPage message before running getOperatorList, and the Display layer resolves it in _startRenderPage. Until this point the operatorList is still empty; chunks are only streamed in afterwards via _renderPageChunk, so the render pipeline must wait for this signal before it can start consuming them.
>
> Note that it means "page resources are ready, ready to start receiving operator chunks", not "the operator list has been fully delivered" — whether everything has arrived is marked by intentState.operatorList.lastChunk.
>
> Additionally, its existence doubles as a cache marker: as long as it already exists, the operator list for this rendering intent (cacheKey) has been requested before, so _pumpOperatorList doesn't need to be called again.
Step 4: instantiate the InternalRenderTask class and add it to the render task queue.
Step 5: wait for intentState.displayReadyCapability to resolve. Then initialize the graphics context with initializeGraphics, and notify that the operator list has changed with operatorListChanged — kicking off the actual rendering logic.
With the overall flow in place, let's analyze the execution logic of each step in detail.
Step 1: getRenderingIntent, computing the rendering intent
Entering PDFPageProxy.render → WorkerTransport.getRenderingIntent
// Source: src/display/api.js
getRenderingIntent(
intent,
annotationMode = AnnotationMode.ENABLE,
printAnnotationStorage = null,
isEditing = false,
isOpList = false
) {
let renderingIntent = RenderingIntentFlag.DISPLAY; // Default value.
let annotationStorageSerializable = SerializableEmpty;
switch (intent) {
case "any":
renderingIntent = RenderingIntentFlag.ANY;
break;
case "display":
break;
case "print":
renderingIntent = RenderingIntentFlag.PRINT;
break;
default:
warn(`getRenderingIntent - invalid intent: ${intent}`);
}
const annotationStorage =
renderingIntent & RenderingIntentFlag.PRINT &&
printAnnotationStorage instanceof PrintAnnotationStorage
? printAnnotationStorage
: this.annotationStorage;
switch (annotationMode) {
case AnnotationMode.DISABLE:
renderingIntent += RenderingIntentFlag.ANNOTATIONS_DISABLE;
break;
case AnnotationMode.ENABLE:
break;
case AnnotationMode.ENABLE_FORMS:
renderingIntent += RenderingIntentFlag.ANNOTATIONS_FORMS;
break;
case AnnotationMode.ENABLE_STORAGE:
renderingIntent += RenderingIntentFlag.ANNOTATIONS_STORAGE;
annotationStorageSerializable = annotationStorage.serializable;
break;
default:
warn(`getRenderingIntent - invalid annotationMode: ${annotationMode}`);
}
if (isEditing) {
renderingIntent += RenderingIntentFlag.IS_EDITING;
}
if (isOpList) {
renderingIntent += RenderingIntentFlag.OPLIST;
}
const { ids: modifiedIds, hash: modifiedIdsHash } =
annotationStorage.modifiedIds;
const cacheKeyBuf = [
renderingIntent,
annotationStorageSerializable.hash,
modifiedIdsHash,
];
return {
renderingIntent,
cacheKey: cacheKeyBuf.join("_"),
annotationStorageSerializable,
modifiedIds,
};
}
There are three modes: any, display, and print.
- any is for getOperatorList / getTextContent and other "any" scenarios.
- display renders the page.
- print prints the page.
Plus the annotationMode option, which controls whether annotations are rendered. And the isEditing editing mode.
Step 2: this._intentStates.getOrInsertComputed
this._intentStates is a Map that caches data keyed by the rendering intent.
makeObj is a utility function: const makeObj = () => Object.create(null);
getOrInsertComputed is a fairly new function in the JS spec: if the key exists, it returns the corresponding value; if it doesn't, it creates a value via the second-argument callback and returns it. See MDN.
Step 3: fetching the page's operator list
Here we check whether intentState.displayReadyCapability exists. If it does, the data was requested before and there's no need to call _pumpOperatorList again.
Otherwise we call _pumpOperatorList to request the page's operator list.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList
// Source: src/display/api.js
_pumpOperatorList({
renderingIntent,
cacheKey,
annotationStorageSerializable,
modifiedIds,
}) {
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
assert(
Number.isInteger(renderingIntent) && renderingIntent > 0,
'_pumpOperatorList: Expected valid "renderingIntent" argument.'
);
}
const { map, transfer } = annotationStorageSerializable;
const readableStream = this._transport.messageHandler.sendWithStream(
"GetOperatorList",
{
pageId: this.#pagesMapper.getPageId(this._pageIndex + 1) - 1,
pageIndex: this._pageIndex,
intent: renderingIntent,
cacheKey,
annotationStorage: map,
modifiedIds,
},
/* queueingStrategy = */ undefined,
transfer
);
const reader = readableStream.getReader();
const intentState = this._intentStates.get(cacheKey);
intentState.streamReader = reader;
const pump = () => {
reader.read().then(
({ value, done }) => {
if (done) {
intentState.streamReader = null;
return;
}
if (this._transport.destroyed) {
return; // Ignore any pending requests if the worker was terminated.
}
this._renderPageChunk(value, intentState);
pump();
},
reason => {
intentState.streamReader = null;
if (this._transport.destroyed) {
return; // Ignore any pending requests if the worker was terminated.
}
if (intentState.operatorList) {
// Mark operator list as complete.
intentState.operatorList.lastChunk = true;
for (const internalRenderTask of intentState.renderTasks) {
internalRenderTask.operatorListChanged();
}
this.#tryCleanup();
}
if (intentState.displayReadyCapability) {
intentState.displayReadyCapability.reject(reason);
} else if (intentState.opListReadCapability) {
intentState.opListReadCapability.reject(reason);
} else {
throw reason;
}
}
);
};
pump();
}
Here, through the transport, we send an important streaming event to the worker: GetOperatorList.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList
handler.on(
'GetOperatorList',
function ({ pageId, pageIndex, intent, cacheKey, annotationStorage, modifiedIds }, sink) {
pdfManager.getPage(pageId).then(function (page) {
const task = new WorkerTask(`GetOperatorList: page ${pageIndex}`);
startWorkerTask(task);
// NOTE: Keep this condition in sync with the `info` helper function.
const start = verbosity >= VerbosityLevel.INFOS ? Date.now() : 0;
// Pre compile the pdf page and fetch the fonts/images.
page
.getOperatorList({
handler,
sink,
task,
intent,
cacheKey,
annotationStorage,
modifiedIds,
pageIndex,
})
.then(
opListInfo => {
if (start) {
info(`${task.name}; time=${Date.now() - start}ms, len=${opListInfo.length}`);
}
sink.close();
},
reason => {
if (task.terminated) {
return; // ignoring errors from the terminated thread
}
sink.error(reason);
},
)
.finally(() => {
finishWorkerTask(task);
});
});
},
);
It calls pdfManager.getPage to get the Page instance. This call points to NetworkPDFManager.getPage, which in turn points to BasePdfManager.getPage.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.getPage (parent BasePdfManager.getPage)
getPage(pageIndex) {
return this.pdfDocument.getPage(pageIndex);
}
which calls this.pdfDocument.getPage.
Let's first see where this.pdfDocument comes from.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.constructor
this.pdfDocument = new PDFDocument(this, this.streamManager.getStream());
instantiating PDFDocument.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.constructor → PDFDocument.constructor
constructor(pdfManager, stream) {
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
assert(
stream instanceof BaseStream,
'PDFDocument: Invalid "stream" argument.'
);
}
if (stream.length <= 0) {
throw new InvalidPDFException(
"The PDF file is empty, i.e. its size is zero bytes."
);
}
this.pdfManager = pdfManager;
this.stream = stream;
this.xref = new XRef(stream, pdfManager);
const idCounters = {
font: 0,
};
this._globalIdFactory = class {
static getDocId() {
return `g_${pdfManager.docId}`;
}
static createFontId() {
return `f${++idCounters.font}`;
}
static createObjId() {
unreachable("Abstract method `createObjId` called.");
}
static getPageObjId() {
unreachable("Abstract method `getPageObjId` called.");
}
};
}
Here, an XRef instance is created to parse the PDF file's XRef table.
> Q: What is the XRef table?
>
> A: The XRef (Cross-Reference) table is the PDF file's "object directory". Every entity inside a PDF — pages, fonts, images, content streams, etc. — is a numbered object (e.g. 3 0 obj). The XRef table records the byte offset of each object within the file.
>
> For example:
>
> > ```
> > xref
> > 0 5
> > 0000000000 65535 f
> > 0000000010 00000 n
> > 0000000053 00000 n
> > 0000000102 00000 n
> > 0000000173 00000 n
> > ```
>
> The first line, 0 5, means the next 5 entries list the objects starting from object 0. Each entry is exactly 20 bytes: the first 10 digits are the byte offset, the middle 5 digits are the generation number, and the last one is n (in-use) or f (free/deleted).
>
> When the parser gets an object reference (say 2 0 R, meaning "reference object 2"), it looks up the XRef table and computes the object's location in the file directly, instead of scanning the file from the start like XML/JSON. This is also the key to PDF's incremental updates (append-only): after each append-only modification, you only need to append a new XRef section and point the startxref pointer at the end of the file to the latest one.
>
> In pdf.js, the XRef class parses this table, maintains the {object number → offset} mapping, and resolves the actual content of objects (when it hits an indirect reference like 2 0 R, it recursively parses until it gets the real value).
> This is one of the optimization points in StreamPDF: the XRef table is pre-parsed on the server side. We won't expand on it here — a dedicated article will introduce it in detail later.
We won't go deeper for now — back to rendering. A later article will explore PDF.js's lexical and syntactic parsing of PDF documents.
Now that PDFDocument is initialized, let's return to the getPage method.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.getPage → PDFDocument.getPage
getPage(pageIndex) {
const cachedPromise = this.#pagePromises.get(pageIndex);
if (cachedPromise) {
return cachedPromise;
}
const { catalog, linearization, xfaFactory } = this;
let promise;
if (xfaFactory) {
promise = Promise.resolve([Dict.empty, null]);
} else if (linearization?.pageFirst === pageIndex) {
promise = this.#getLinearizationPage(pageIndex);
} else {
promise = catalog.getPageDict(pageIndex);
}
promise = promise.then(
([pageDict, ref]) =>
new Page({
pdfManager: this.pdfManager,
xref: this.xref,
pageIndex,
pageDict,
ref,
globalIdFactory: this._globalIdFactory,
fontCache: catalog.fontCache,
builtInCMapCache: catalog.builtInCMapCache,
standardFontDataCache: catalog.standardFontDataCache,
globalColorSpaceCache: catalog.globalColorSpaceCache,
globalImageCache: catalog.globalImageCache,
systemFontCache: catalog.systemFontCache,
nonBlendModesSet: catalog.nonBlendModesSet,
xfaFactory,
})
);
this.#pagePromises.set(pageIndex, promise);
return promise;
}
It reads the catalog, checks linearization, and creates the page.
> Q: What is the catalog?
> A: The Catalog is the PDF file's "root dictionary", sitting at the top of the object graph. The /Root entry in the trailer at the end of the file points to it — it's the entry point of the entire PDF. It holds references to all other top-level structures: most importantly /Pages (the root of the page tree), plus /Names (named objects), /Outlines (bookmarks), /AcroForm (forms), /Metadata (XMP metadata), and more.
>
> In pdf.js, the Catalog class wraps this root dictionary and provides caches shared across pages — the font cache (fontCache), the global color space cache (globalColorSpaceCache), the image cache (globalImageCache), the built-in CMap cache (builtInCMapCache), etc. These caches are reused across pages to avoid re-parsing the same font definitions or color spaces — which is why the Page constructor above takes that long list of catalog.fontCache, catalog.globalColorSpaceCache, and so on.
> Q: What is linearization?
> A: Linearization is a "fast web view" optimization provided by the PDF specification.
>
> A normal PDF is parsed like this: jump to the end of the file and read startxref → read the trailer → find the main XRef section from the trailer → only then can objects be located. That means you need the tail of the file before you can start working.
>
> A linearized PDF additionally places a Linearized dictionary at the beginning of the file (at object 1), which records:
>
> - N — total page count
> - P — the index of the first page (default 0)
> - O — the object number of the first page
> - T — the offset of the main XRef section in the file
> - H — the hint arrays, telling the reader which pages live where in the file
>
> With just a few hundred bytes from the start of the file, the parser can use this dictionary to skip the main XRef and jump straight to the first page's data to start rendering, without waiting for the whole file to download. The effect is dramatic on large files (tens-of-MB scanned documents).
>
> In pdf.js, the PDFDocument.linearization getter tries to parse the beginning of the file; if it finds this Linearized dictionary, it returns an object with fields like numPages, pageFirst, and objectNumberFirst. The getPage logic above is: if the PDF is linearized and the requested page is the first page, take the #getLinearizationPage fast path and use linearization.objectNumberFirst to get the first page's dictionary directly; otherwise fall back to the standard catalog lookup.
> But linearization depends heavily on the tool that produced the file — most PDFs have never been linearized.
We've now touched some real PDF format specifications.
This returns the Page instance — let's stay restrained and not dig deeper.
Back to PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList
Continuing:
// Pre compile the pdf page and fetch the fonts/images.
page
.getOperatorList({
handler,
sink,
task,
intent,
cacheKey,
annotationStorage,
modifiedIds,
pageIndex,
})
.then(
opListInfo => {
if (start) {
info(`${task.name}; time=${Date.now() - start}ms, len=${opListInfo.length}`);
}
sink.close();
},
reason => {
if (task.terminated) {
return; // ignoring errors from the terminated thread
}
sink.error(reason);
},
)
.finally(() => {
finishWorkerTask(task);
});
It calls the Page.getOperatorList method to get the page's rendering operators.
Let's take a quick look at the implementation of Page.getOperatorList.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → Page.getOperatorList
// Source: src/core/document.js
async getOperatorList({
handler,
sink,
task,
intent,
cacheKey,
pageIndex = this.pageIndex,
annotationStorage = null,
modifiedIds = null,
}) {
const contentStreamPromise = this.getContentStream();
const resourcesPromise = this.loadResources(RESOURCES_KEYS_OPERATOR_LIST);
// ...
const pageListPromise = Promise.all([
contentStreamPromise,
resourcesPromise,
]).then(async ([contentStream]) => {
const resources = await this.#getMergedResources(
contentStream.dict,
RESOURCES_KEYS_OPERATOR_LIST
);
const opList = new OperatorList(intent, sink);
handler.send("StartRenderPage", {
transparency: partialEvaluator.hasBlendModes(
resources,
this.nonBlendModesSet
),
pageIndex,
cacheKey,
});
await partialEvaluator.getOperatorList({
stream: contentStream,
task,
resources,
operatorList: opList,
});
return opList;
});
// ...
A lot of logic is omitted — but did you spot the key handler.send("StartRenderPage")? This event corresponds to the intentState.displayReadyCapability we saw earlier.
The main thread's StartRenderPage event listener:
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → Page.getOperatorList → Main: on StartRenderPage
messageHandler.on('StartRenderPage', data => {
if (this.destroyed) {
return; // Ignore any pending requests if the worker was terminated.
}
const page = this.#pageCache.get(data.pageIndex);
page._startRenderPage(data.transparency, data.cacheKey);
});
which calls page._startRenderPage.
/**
* @private
*/
_startRenderPage(transparency, cacheKey) {
const intentState = this._intentStates.get(cacheKey);
if (!intentState) {
return; // Rendering was cancelled.
}
this._stats?.timeEnd("Page Request");
// TODO Refactor RenderPageRequest to separate rendering
// and operator list logic
intentState.displayReadyCapability?.resolve(transparency);
}
This method resolves the intentState.displayReadyCapability promise.
OK, let's return to the _pumpOperatorList method.
Back to PDFPageProxy.render → PDFPageProxy._pumpOperatorList
Continuing with the streaming data: through the sink, data keeps flowing back to the reader on the main thread.
intentState.streamReader = reader;
const pump = () => {
reader.read().then(
({ value, done }) => {
if (done) {
intentState.streamReader = null;
return;
}
if (this._transport.destroyed) {
return; // Ignore any pending requests if the worker was terminated.
}
this._renderPageChunk(value, intentState);
pump();
},
reason => {
intentState.streamReader = null;
if (this._transport.destroyed) {
return; // Ignore any pending requests if the worker was terminated.
}
if (intentState.operatorList) {
// Mark operator list as complete.
intentState.operatorList.lastChunk = true;
for (const internalRenderTask of intentState.renderTasks) {
internalRenderTask.operatorListChanged();
}
this.#tryCleanup();
}
if (intentState.displayReadyCapability) {
intentState.displayReadyCapability.reject(reason);
} else if (intentState.opListReadCapability) {
intentState.opListReadCapability.reject(reason);
} else {
throw reason;
}
},
);
};
pump();
Every time the worker pushes a chunk into the stream, it's consumed here, which calls this._renderPageChunk(value, intentState);
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk
_renderPageChunk(operatorListChunk, intentState) {
// Add the new chunk to the current operator list.
for (let i = 0, ii = operatorListChunk.length; i < ii; i++) {
intentState.operatorList.fnArray.push(operatorListChunk.fnArray[i]);
intentState.operatorList.argsArray.push(operatorListChunk.argsArray[i]);
}
intentState.operatorList.lastChunk = operatorListChunk.lastChunk;
intentState.operatorList.separateAnnots = operatorListChunk.separateAnnots;
// Notify all the rendering tasks there are more operators to be consumed.
for (const internalRenderTask of intentState.renderTasks) {
internalRenderTask.operatorListChanged();
}
if (operatorListChunk.lastChunk) {
this.#tryCleanup();
}
}
After pre-processing the data, it calls internalRenderTask.operatorListChanged() to notify the render tasks that new operators have arrived.
Step 4: the render tasks have new operators
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged
operatorListChanged() {
if (!this.graphicsReady) {
this.graphicsReadyCallback ||= this._continueBound;
return;
}
this.gfx.dependencyTracker?.growOperationsCount(
this.operatorList.fnArray.length
);
this.stepper?.updateOperatorList(this.operatorList);
if (this.running) {
return;
}
this._continue();
}
which goes to _continue.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue
_continue() {
this.running = true;
if (this.cancelled) {
return;
}
if (this.task.onContinue) {
this.task.onContinue(this._scheduleNextBound);
} else {
this._scheduleNext();
}
}
which goes to _scheduleNext.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue → internalRenderTask._scheduleNext
_scheduleNext() {
if (this._useRequestAnimationFrame) {
this.#rAF = window.requestAnimationFrame(() => {
this.#rAF = null;
this._nextBound().catch(this._cancelBound);
});
} else {
Promise.resolve().then(this._nextBound).catch(this._cancelBound);
}
}
Via window.requestAnimationFrame, it schedules the _nextBound method.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue → internalRenderTask._scheduleNext → internalRenderTask._nextBound
In the constructor, this._nextBound = this._next.bind(this);
which goes to the _next method.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue → internalRenderTask._scheduleNext → internalRenderTask._nextBound → internalRenderTask._next
async _next() {
if (this.cancelled) {
return;
}
this.operatorListIdx = this.gfx.executeOperatorList(
this.operatorList,
this.operatorListIdx,
this._continueBound,
this.stepper,
this._operationsFilter
);
if (this.operatorListIdx === this.operatorList.argsArray.length) {
this.running = false;
if (this.operatorList.lastChunk) {
this.gfx.endDrawing();
InternalRenderTask.#canvasInUse.delete(this._canvas);
this.callback();
}
}
}
It calls this.gfx.executeOperatorList — the operator list is now actually executed.
Step 5: initializeGraphics & operatorListChanged
Once intentState.displayReadyCapability.promise is ready, the graphics context is initialized.
Entering initializeGraphics
// Source: src/display/api.js (InternalRenderTask)
initializeGraphics({ transparency = false, optionalContentConfig }) {
if (this.cancelled) {
return;
}
if (this._canvas) {
if (InternalRenderTask.#canvasInUse.has(this._canvas)) {
throw new Error(
"Cannot use the same canvas during multiple render() operations. " +
"Use different canvas or ensure previous operations were " +
"cancelled or completed."
);
}
InternalRenderTask.#canvasInUse.add(this._canvas);
}
if (this._pdfBug && globalThis.StepperManager?.enabled) {
this.stepper = globalThis.StepperManager.create(this._pageIndex);
this.stepper.init(this.operatorList);
this.stepper.nextBreakPoint = this.stepper.getNextBreakPoint();
}
const {
viewport,
transform,
background,
dependencyTracker,
imagesTracker,
} = this.params;
// When printing in Firefox, we get a specific context in mozPrintCallback
// which cannot be created from the canvas itself.
const canvasContext =
this._canvasContext ||
this._canvas.getContext("2d", {
alpha: false,
willReadFrequently: !this._enableHWA,
});
this.gfx = new CanvasGraphics(
canvasContext,
this.commonObjs,
this.objs,
this.canvasFactory,
this.filterFactory,
{ optionalContentConfig },
this.annotationCanvasMap,
this.pageColors,
dependencyTracker,
imagesTracker
);
this.gfx.beginDrawing({
transform,
viewport,
transparency,
background,
});
this.operatorListIdx = 0;
this.graphicsReady = true;
this.graphicsReadyCallback?.();
}
By setting this.graphicsReady = true, the internalRenderTask.operatorListChanged method is allowed to proceed.
It then immediately triggers operatorListChanged once more, starting execution of the operator list.
operatorListChanged() {
if (!this.graphicsReady) {
this.graphicsReadyCallback ||= this._continueBound;
return;
}
this.gfx.dependencyTracker?.growOperationsCount(
this.operatorList.fnArray.length
);
this.stepper?.updateOperatorList(this.operatorList);
if (this.running) {
return;
}
this._continue();
}
Summary
In this article, we explored the Display layer's getViewport and render methods.
The core of getViewport is building an affine transform matrix: converting PDF's coordinate system (origin at the bottom-left, unit = point) into canvas's coordinate system (origin at the top-left, unit = px), while handling rotation, scale, and dontFlip.
render is a rendering pipeline spanning the main thread and the worker:
!Image description
Text version:
Main thread (PDFPageProxy.render)
|-- getRenderingIntent compute the intent, build the cache key
|-- _intentStates.getOrInsert... get or create the intent state by cache key
|-- _pumpOperatorList send the streaming GetOperatorList request to the worker
| `-- reader.read() loop consumes chunks, hands each to _renderPageChunk
|-- new InternalRenderTask create the render task, add it to renderTasks
`-- await displayReadyCapability
`-- initializeGraphics create CanvasGraphics, beginDrawing
`-- operatorListChanged -> _next -> gfx.executeOperatorList
Worker thread (on:GetOperatorList)
|-- pdfManager.getPage locate the Page
`-- Page.getOperatorList
|-- handler.send("StartRenderPage") resolves displayReadyCapability
`-- partialEvaluator.getOperatorList parses operators, streams them back via sink
Understanding this pipeline means understanding the skeleton of PDF.js rendering: the main thread only schedules and consumes operators; the real parsing work all happens in the worker.
At this point, after these three articles, the rendering architecture of PDF.js is clear. Starting from the next article, we'll dive into some of the details to round out the depth.
Happy reading!
Next up: MessageHandler — a deep dive into the core mechanism of message communication.
12 Free Browser-Based PDF Tools for Developers (No Upload, No Sign-Up)
I build DonePDF, a free site with 90+ PDF tools. Most of them run entirely in the browser, which turned out to be handy for developer work: no upload, no account, and the file never leaves your machine.
Here are the ones I find most useful when PDFs show up in dev work, grouped by what you're trying to do.
📝 Get content out of a PDF
PDF to Markdown
Turns a PDF into GitHub-flavored Markdown: headings, paragraphs, lists and simple tables, with a live preview. Great for moving old docs into a repo or a wiki.
PDF to JSON
Text per page, document metadata and optional text coordinates. Pick the pages you need, and use OCR for scanned files. Useful when you need to feed PDF content into a script or a pipeline.
PDF to HTML
Either clean responsive text with headings and lists, or exact page images with selectable text on top.
🖨️ Generate PDFs
Markdown to PDF
README or docs to PDF with tables, task lists, code blocks, links and images. Set the page size and margins, and the text stays selectable.
HTML to PDF
Paste HTML or upload an .html file and get a PDF with CSS layout, web fonts, images and page breaks. Scripts in the HTML are never run.
JSON to PDF
Validates the JSON (with line and column on errors) and prints it as colored, pretty-printed code or as a key-path table. Handles files up to 30 MB.
🔍 Debug and inspect PDFs
Compare PDF
Word-level text diff between two PDFs, plus added and removed pages, with overlay, swipe and difference views. Basically git diff for PDFs.
Validate PDF
Is the file structure intact? Are fonts embedded? Is it scanned, encrypted, or carrying scripts? You get a report without uploading anything.
Analyze PDF
Text per page, word and character counts, reading time, metadata, and search with hits per page.
Sanitize PDF
See and remove hidden data: author and properties, XMP metadata, attachments, embedded JavaScript, comments and form data. Worth running before you publish a PDF anywhere.
🧩 Small utilities
PDF to Base64
Encode a PDF as a Base64 string, optionally with the data:application/pdf;base64, prefix, for tests, fixtures or API payloads.
Base64 to PDF
The reverse: paste a Base64 string from a log or an API response and get the PDF back.
Repair PDF
Rebuilds a broken file structure and recovers the pages that can still be read, with a report of what was fixed.
🛠️ How it works under the hood
The browser-based tools are built on open-source libraries:
- pdf.js to read and render pages
- pdf-lib to write changes back into the PDF
- Fabric.js for the editing canvas in the online PDF editor
- Tesseract.js for OCR on scanned pages
A few heavier tools still run on the server, and their pages say so.
---
What PDF task do you still do by hand or with a script you'd rather not maintain? I'm always looking for the next tool to build, so let me know in the comments. 👇
I built a bank statement to Excel converter that never uploads your PDF
Most "PDF bank statement to Excel" sites want you to upload the file. Think about what's on that PDF: your name, your address, your account number, and every payment you made last month. I wouldn't hand that to a site I'd never heard of, so when I built one for my own site I set one rule before writing any code. The file never leaves the browser tab.
This is how it works, including the parts that are still fragile. It's plain JavaScript, one ES module of about 230 lines plus Mozilla's pdf.js, with no backend at all. If you'd rather try it before reading, it's live as a free bank statement to Excel converter on my studio's site.
Reading text out of a PDF
A PDF doesn't contain lines or tables. It contains text fragments, each with a position on the page, and the viewer simply draws them where they belong. pdf.js hands you those fragments through getTextContent(), and every item carries a transform matrix whose last two values are its x and y coordinates.
I self-host pdf.js (the module and its worker are about 1.7 MB together) instead of loading it from a CDN, because "nothing leaves the tab" should also mean no third party learns that you opened the tool. It's loaded with isEvalSupported: false as well, which stops pdf.js from compiling code with eval; that path was behind CVE-2024-4367, and a tool that opens files from strangers' banks has no reason to keep it on.
PDF coordinates start at the bottom left, so a higher y is further up the page, hence b.y - a.y. Fragments within 2.5 units of each other vertically count as one line. That number came from trial and error: banks don't always put a date and its amount on exactly the same baseline, and with a tolerance of zero half the rows split in two.
Which lines are transactions?
A line counts as a transaction when it starts with a date and contains at least one money amount. Both of those turned out to be harder than they sound.
The parser knows five date shapes: 2026-10-03, 03/10/2026, 3 Oct 2026, Oct 3, 2026, and a short 03/10 with no year at all. The month names include the Dutch ones (mrt, mei, okt), because I'm based in Rotterdam and wanted Dutch statements to work from day one.
Then there's the classic problem. Is 03/10/2026 the 3rd of October or the 10th of March? One date can't tell you, but a whole statement usually gives it away:
let dmy = 0, mdy = 0;
for (const l of lines) {
const m = l.cells.map((c) => c.s).join(' ').match(/^(\d{1,2})[./-](\d{1,2})[./-]\d{2,4}/);
if (m) { if (+m[1] > 12) dmy++; if (+m[2] > 12) mdy++; }
}
A date whose first number is above 12 has to be day first, and the other way round. If a statement only covers the first twelve days of a month there's no evidence either way, and the tool falls back to looking for a dollar sign or "USD" near the top of the document. That fallback is a guess, so the detected order is shown on screen next to a dropdown to override it.
Amounts are one long regex. It accepts an optional currency sign, thousands separators (comma, dot, space or apostrophe, since Swiss statements write 1'250.00), exactly two decimals and an optional trailing flag. The flag matters more than I expected, because plenty of statements never print a minus sign and put CR or DR after the number instead, or Af and Bij on Dutch ones. Requiring exactly two decimals is what keeps reference numbers and page numbers from being read as money.
Debit, credit or balance?
A transaction line often holds two or three amounts: the money going out or coming in, and the running balance. So the parser first looks for a header row (Debit, Withdrawals, Paid out, Bedrag af, Balance, Saldo and so on) and remembers the horizontal centre of each heading. Every amount on a transaction line then goes to the column whose heading is nearest in x. Under "Paid out" it becomes negative, under "Paid in" positive, and under "Balance" it lands in the balance column.
Without a recognisable header it falls back to a cruder rule, where the first amount is the transaction and the last one is the balance. That's right more often than you'd think, and wrong often enough that every cell in the results table is editable.
Two smaller rules clean up the rest. Lines like "Opening balance" or "Balance brought forward" are skipped, since to everything above they look exactly like transactions. And a line with no date and no amount, shorter than 120 characters, is appended to the previous transaction's description, which keeps descriptions that wrap onto a second line in one cell.
Writing an .xlsx without a library
CSV was easy, as long as you put a byte order mark in front; without it, Excel on Windows mangles every character that isn't ASCII. For a real .xlsx I didn't want a second large dependency next to pdf.js just to write a file with four columns.
An .xlsx is a zip archive of XML files, and a zip doesn't have to be compressed. The "stored" method writes each file behind a small header with a CRC-32 checksum and finishes with a central directory that lists them all. My writer is about 40 lines, plus six XML files as template strings: the content types, two relationship files, the workbook, a style sheet and the sheet itself.
The only part that isn't obvious is dates. Excel stores a date as a number of days since its own epoch, so converting a JavaScript date is one line:
const serial = (d) => d.getTime() / 86400000 + 25569;
25569 is Excel's serial number for 1 January 1970. The value only displays as a date because the cell points at a style with the format yyyy-mm-dd; leave the style out and the cell shows something like 46298, which is correct and completely useless. The header row is frozen, the description column is wide, and descriptions are written as inline strings, so there's no shared-strings table to maintain.
How do you know nothing is uploaded?
You shouldn't have to take my word for it. The module reads the file with file.arrayBuffer(), parses it in memory and builds the download with URL.createObjectURL(). Nothing in it sends the file, its text or a single number anywhere. The site's Content Security Policy only allows connections back to its own domain, and if you keep the Network tab open while converting a statement, you won't see a request carrying your data. "Clear" empties the table, and nothing is written to storage at any point.
What it doesn't do yet
Scanned statements don't work. When a PDF is a picture of a page there's no text layer for pdf.js to read, and the tool says so instead of guessing. OCR in the browser is possible, but it would make the page a lot heavier, and I'm honestly not sure it's worth it for a free tool.
There's also a bug I know about and haven't fixed. Short dates without a year take their year from the first four-digit year found anywhere in the document. So a statement running from December into January, from a bank that prints dates that way, puts the January transactions in the wrong year. It's an edge case, but it's exactly the kind that ends up in somebody's annual accounts, which is why the tool shows you every row before it lets you download.
Every bank lays out its statement a little differently, so I expect to find more of these. If one of yours comes out wrong, the editable table will get you through it today, and I'd really like to hear which bank it was.
---
I'm Philip, a Microsoft-certified Power BI analyst from Rotterdam. I recently launched Flippie Labs, where I build dashboards and reporting automation for B2B teams. The bigger version of this problem, where invoices, receipts and statements arrive as PDFs and need to become clean tables every month, is what my documents-to-data service is for.
I built an ATS CV checker that never uploads your CV (pdf.js, jszip and a lot of heuristics)
!Image description
Every job seeker eventually hears the same scary line: "an ATS rejected your CV before a human ever saw it."
It's mostly not true. An applicant tracking system (ATS) is the software companies use to collect applications. It reads the text out of your CV (or résumé), drops it into a candidate profile, and lets recruiters search it. Most rejections still come from people, knockout questions on the form, or the sheer number of applicants.
But there's a real problem hiding under the myth: if the ATS can't read your file properly, your details end up jumbled or missing. Your two-column layout gets read straight across, your phone number was an icon, your "Experience" heading was a picture of the word "Experience". The recruiter searching for "SQL" never finds you.
I work on Litjob, an AI job application assistant, and I wanted a free tool that shows people exactly that: here's what an ATS gets out of your file. I had one hard rule.
The rule: the CV never leaves the browser
A CV is about the most personal document people have. Name, phone, address, work history, sometimes a photo and a date of birth. I didn't want to receive it, store it, or explain a privacy policy about it.
That rule also happened to be the cheap option. No upload endpoint, no storage, no AI calls per check, no server bill that grows with traffic. The whole check is a pure function that runs on the visitor's laptop.
So the plan became:
1. Read the PDF or Word file in the browser.
2. Pull out the text in the order a text extractor reads it, plus a few layout facts (where text sits on the page, images, tables, text boxes, headers).
3. Run checks on those facts and explain each problem in plain English.
Reading PDFs with pdf.js
pdf.js is Mozilla's PDF engine, the one inside Firefox. It's big: the worker alone is about 1 MB minified. Loading that for every visitor to a marketing page would be silly, so it only loads when someone actually picks a file:
async function readPdf(file: File) {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
const doc = await pdfjs.getDocument({
data: new Uint8Array(await file.arrayBuffer()),
isEvalSupported: false,
}).promise;
// ...
}
The new URL(..., import.meta.url) trick lets the bundler (Next.js here) copy the worker file into the build and hand back its URL. I was half expecting to have to copy the worker into public/ by hand and keep its version in sync forever. I didn't.
For each page, getTextContent() gives you text items in content-stream order, which is roughly what a naive extractor sees. Each item also has a transform matrix, so you know where it sits on the page. I normalise everything to fractions of the page size, so a US Letter page and an A4 page look the same to the checks:
const [left, bottom, right, top] = page.view;
const width = right - left;
const height = top - bottom;
for (const item of content.items) {
if (!('str' in item)) continue;
text += item.str + (item.hasEOL ? '\n' : '');
items.push({
page: pageNumber,
x: (item.transform[4] - left) / width,
y: 1 - (item.transform[5] - bottom) / height, // 0 = top of the page
width: item.width / width,
text: item.str,
});
}
Images come from the operator list. Count the "paint image" operators and you know how many pictures and icons are on the page:
const imageOps = new Set([
pdfjs.OPS.paintImageXObject,
pdfjs.OPS.paintInlineImageXObject,
pdfjs.OPS.paintImageMaskXObject,
pdfjs.OPS.paintImageXObjectRepeat,
]);
const operators = await page.getOperatorList();
imageCount += operators.fnArray.filter((op) => imageOps.has(op)).length;
The first check is the most important one, and the simplest: if a PDF gives back fewer than about 60 words, it's almost certainly a scan or a picture of a CV. To an ATS that's a blank document. I stop there and say so, because nothing else matters until that's fixed.
Spotting two-column CVs (the heuristic that took the most thought)
Two-column templates are everywhere, and they're the classic way to get your sidebar and your work history shuffled together line by line.
The obvious idea is "lots of text starts in the middle of the page, so it's two columns." That would flag nearly every normal CV, because normal CVs are full of things that sit to the right:
- right-aligned dates (Mar 2021 – Present)
- indented bullet points
- the second half of a long line
What ended up working: for each page, look only at substantial runs of text (at least 12 characters, at least 12% of the page wide) that start between 28% and 70% of the way across. Round their start positions into buckets and find the most common one. If at least six runs start at that same spot, and at least six other runs start at the left margin and stop before it, that's a column.
for (const item of pageItems) {
if (item.x >= 0.28 && item.x <= 0.7 && item.width >= 0.12 && item.text.trim().length >= 12) {
const bucket = Math.round(item.x * 25) / 25;
middleStarts.set(bucket, (middleStarts.get(bucket) ?? 0) + 1);
}
}
// ...find the busiest bucket, then count left-margin runs that end before it
Dates are short, so they don't count. Bullets start near the left margin, so they don't count. A sidebar plus a main column does count. I tested it on printed HTML CVs (a one-column one and a sidebar one) and it got both right. It will still be wrong sometimes, which is why the result says "your CV looks like it has two columns" and then shows the extracted text so people can judge for themselves.
That last part turned out to be the most useful feature of the whole tool. People don't need my verdict. They need to see their sidebar text shuffled into their job history.
Icons are secretly characters
This one surprised me. Lots of CV templates use an icon font for the little phone, email and location symbols. When you extract the text, those icons come out as characters from Unicode's Private Use Area (U+E000 to U+F8FF): code points with no agreed meaning, which fonts fill with whatever they like. Broken font encodings show up as the replacement character (U+FFFD) or as (cid:123) sequences.
To an ATS, your phone number now starts with a meaningless character, or the email icon is glued onto the address. So the check counts them:
function unreadableCharacterCount(text: string): number {
return (text.match(/[\uE000-\uF8FF\uFFFD]|\(cid:\d+\)/g) ?? []).length;
}
Funny story: my tooling quietly turned those \uE000 escapes into the actual invisible characters when the file was first written. The code still worked, because a character class made of the real characters means the same thing, but on screen the line read /[-]/, which is a great way to confuse the next person. I only caught it while rereading the code to write this post. They're escapes again now.
Letter-spaced headings are the cousin of this problem: E X P E R I E N C E looks elegant and reads as ten one-letter words. A small regex catches runs of single letters separated by spaces.
Word files are zip files full of XML
A .docx is a zip archive. Inside, word/document.xml holds the body, word/header1.xml and friends hold headers and footers, and docProps/app.xml usually has a page count. So: jszip to open it, the browser's own DOMParser to read the XML, and no Word library at all.
const { default: JSZip } = await import('jszip');
const zip = await JSZip.loadAsync(await file.arrayBuffer());
const xml = await zip.file('word/document.xml')?.async('string');
const body = new DOMParser().parseFromString(xml, 'application/xml');
const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
const tables = body.getElementsByTagNameNS(W, 'tbl').length;
const textBoxes = body.getElementsByTagNameNS(W, 'txbxContent').length;
Tables and text boxes are worth flagging because some ATS read tables cell by cell in an odd order, and many skip text boxes entirely.
Then there's a trap that's easy to fall into. Word saves shapes and text boxes twice, on purpose: a modern version inside mc:AlternateContent > mc:Choice, and a fallback copy inside mc:Fallback for old versions of Word. Both contain the same text, so a naive count reports two text boxes where there's one, and their text appears twice. Every count needs to skip anything under a Fallback element:
const MC = 'http://schemas.openxmlformats.org/markup-compatibility/2006';
function isFallbackCopy(node: Element): boolean {
for (let parent = node.parentElement; parent; parent = parent.parentElement) {
if (parent.namespaceURI === MC && parent.localName === 'Fallback') return true;
}
return false;
}
Headers got their own check too. Lots of people put their name, email and phone in the Word header so it repeats on every page. Some ATS ignore headers and footers. If the email only appears in the header, the tool says so.
The rest of the checks are boring, which is the point
After the text is out, everything else is plain string work:
- Contact details: an email regex, plus a phone check that requires 9 to 15 digits, because a loose phone regex happily matches a date range like 2019 - 2021.
- Headings: short lines on their own that look like "Work experience", "Education" and "Skills". If "Where I've been" is the only heading for your jobs, you get a gentle nudge.
- Dates: at least a couple of Mar 2021 – Present or 03/2021 style dates, since an ATS works out your years of experience from them.
- Length and file name: CV (2).pdf and resume_final.docx look like every other file in the pile.
All the judging lives in one pure function, checkAtsReadability(facts), in a shared package with unit tests. The browser code only gathers facts. That split made it easy to test the awkward cases (a scanned PDF, contact details only in a header, a fake two-column page) without touching a real file.
What it can't do
I want to be clear about this, because the job-search internet is full of tools promising an "ATS score":
- It isn't an ATS score. There's no such universal number. Every company configures its ATS differently, and no outside tool can see it. This tool tells you whether your file is readable, nothing more.
- PDF tables are invisible to it. A PDF doesn't record "this is a table", only lines and text at positions, so I only check tables in Word files.
- The column detector is a heuristic. It's right on my test CVs, but someone will have a layout that fools it. Showing the extracted text is the safety net.
Try it, break it
The tool is here: getlitjob.com. It's free, there's no sign-up, and you can open your browser's network tab and check that your file never gets uploaded.
If you have a CV that it gets wrong (especially a weird layout that fools the column check), I'd genuinely like to hear about it in the comments. Heuristics only get better when someone breaks them.
1 reaction · 1 comment
Mentions
PDF.js Deep Dive from Demo 2: getDocument Introduction The demo in the previous article called getDocument with only { url: '...' }. This article follows that single call through the full execution chain of getDocument. The getDocument call The source parameter is named src. The demo passes only one key: | Key in src | Value | | ------------ | -------------------------------------------------- | | url | 'https://assets-blog.streampdf.com/PDF32000_2008.pdf' | Based on the definition of getDocument in display/api.js, the execution flow is as follows. Lines 235-236: Initialize the loading task Instantiate the PDFDocumentLoadingTask class: view source, and assign the return value to the task variable. Extract docId from task; at this point docId is d0. Lines 238-335: Validate and process parameters This block of code expands the user-provided src object into 35+ local variables: provided values are validated and type-converted, while missing ones fall back to defaults. The demo only passes url, so the vast majority of parameters take the default branch. The only provided url is converted into a URL object by getUrlProp: const url = URL.parse('https://assets-blog.streampdf.com/PDF32000_2008.pdf', window.location); // const url = src.url ? getUrlProp(src.url) : null; All other parameters are absent. In source order, the computed results are: | Variable | Result | Logic | | ---------------------------- | ------------------------ | ------------------------------------------------------------------------ | | data | null | If provided, normalized to Uint8Array by getDataProp | | httpHeaders | null | Passed through as-is; null when absent | | withCredentials | false | Only accepts === true | | password | null | ?? null | | rangeTransport | null | Only accepts a PDFDataRangeTransport instance | | rangeChunkSize | 2 ** 16 (64KB) | Only accepts the provided value if it is a positive integer | | worker | null | Only accepts a PDFWorker instance | | verbosity | undefined | Taken verbatim from src.verbosity | | docBaseUrl | null | Only accepts a string that is not a data: URL | | cMapUrl | null | getFactoryUrlProp: non-string becomes null, and it must end with / | | cMapPacked | true | !== false | | iccUrl | null | Same as cMapUrl | | standardFontDataUrl | null | Same as cMapUrl | | wasmUrl | null | Same as cMapUrl | | ignoreErrors | true | src.stopAtErrors !== true, see below | | maxImageSize | -1 (unlimited) | Only accepted if an integer and > -1 | | isOffscreenCanvasSupported | true | Defaults to !isNodeJS when absent | | isImageDecoderSupported | true | Defaults to !isNodeJS when absent | | canvasMaxAreaInBytes | -1 (unlimited) | Only accepted if an integer | | disableFontFace | false | Defaults to isNodeJS when absent | | fontExtraProperties | false | Only accepts === true | | enableXfa | false | Only accepts === true | | ownerDocument | globalThis.document | Defaults to the global document when absent | | disableRange | false | Only accepts === true | | disableStream | false | Only accepts === true | | disableAutoFetch | false | Only accepts === true | | pdfBug | false | Only accepts === true | | CanvasFactory | DOMCanvasFactory | Environment branch, see below | | FilterFactory | DOMFilterFactory | Same as above | | BinaryDataFactory | DOMBinaryDataFactory | Same as above | | enableHWA | false | Only accepts === true | | enableWebGPU | false | Only accepts === true | | gpuPromise | Promise.resolve(false) | WebGPU is off, so GPU initialization does not start | | useWasm | true | !== false | | pagesMapper | new PagesMapper() | Created fresh when absent | | useSystemFonts | true | Cascading default, see below | | useWorkerFetch | false | Compound check, see the Q&A below | | styleElement | null | Only preserved in TESTING builds; always null in production | These are all the local variables produced by this block of code. Most of the checks are trivial "fall back to default when absent" cases, but a few deserve a closer look. ignoreErrors: a semantically inverted internal variable The user-facing parameter is called stopAtErrors, yet the internal variable is called ignoreErrors, computed as src.stopAtErrors !== true. In other words, the default behavior is to ignore errors: when parsing fails, try to recover rather than rejecting promises such as getOperatorList / getTextContent / RenderTask. When reading the source, it is very easy to copy the name and value the wrong way around here. rangeChunkSize: the 64KB setup for later rangeChunkSize defaults to 64KB. Since disableRange = false, when the transport is created later, range requests will be issued against the PDF, and each fetched chunk will be exactly this size. Keep it in mind for now. docBaseUrl: why data: URLs are excluded The source comment explains the reason: a data: URL cannot be used to resolve absolute paths, and it contains the entire PDF document with no upper bound on length, so it is not sent to the worker thread. gpuPromise: designed to run in parallel with worker startup const gpuPromise = Promise.resolve(false); // enableWebGPU ? initGPU() : Promise.resolve(false); Even when WebGPU is disabled, this immediately returns a resolved promise instead of deferring the check until it is used. The comment explains the intent: let GPU initialization run in parallel with worker startup, with the resolved boolean forwarded to the worker afterwards. The three Factories: environment branches const CanvasFactory = DOMCanvasFactory; // src.CanvasFactory || (GENERIC build && isNodeJS ? NodeCanvasFactory : DOMCanvasFactory); The same applies to FilterFactory and BinaryDataFactory. The check has three tiers: user-provided custom > Node environment implementation > DOM implementation. The demo runs in a browser without customization, so all three factories are the DOM versions. Their responsibilities: - CanvasFactory: creates canvases for rendering - FilterFactory: creates image filters (such as SVG filters) - BinaryDataFactory: downloads built-in static assets; works together with useWorkerFetch below useSystemFonts: the default depends on other parameters const useSystemFonts = true; // typeof src.useSystemFonts === "boolean" ? src.useSystemFonts : !isNodeJS && !disableFontFace; Its default depends on disableFontFace, which was just parsed above. Why? Fonts not embedded in the PDF are loaded as FontFaces; if disableFontFace is true, that loading path is disabled, and falling back to system fonts at that point would produce completely garbled glyphs. So in that scenario the default is simply false as well, avoiding rendering utterly broken fonts. useWorkerFetch: a compound check const useWorkerFetch = false; // typeof src.useWorkerFetch === "boolean" // ? src.useWorkerFetch // : MOZCENTRAL || // !!( // BinaryDataFactory === DOMBinaryDataFactory && // cMapUrl && cMapPacked && standardFontDataUrl && wasmUrl && // isValidFetchUrl(cMapUrl, document.baseURI) && // isValidFetchUrl(standardFontDataUrl, document.baseURI) && // isValidFetchUrl(wasmUrl, document.baseURI) // ); > Q: What is this check doing? > A: It decides who downloads the built-in static assets (CMap files, standard font files, wasm files): the worker thread downloads them itself via the Fetch API (true), or the main thread downloads them and forwards them to the worker (false). > > If the user explicitly passes a boolean, the user's value is used directly. Otherwise the default is computed: MOZCENTRAL (the Firefox built-in viewer) is always true; for a regular build, all three of the following conditions must hold at the same time for it to be true: > > 1. No custom BinaryDataFactory — because when useWorkerFetch is true, that parameter is simply ignored (inside transportFactory, binaryDataFactory is set to null). A user-provided factory must be respected, so the default cannot be true; > 2. cMapUrl / standardFontDataUrl / wasmUrl are all provided, and cMapPacked is true — without URLs there is nothing to fetch; > 3. These three URLs resolve to http/https relative to document.baseURI — the Fetch API only supports http/https, not file/ftp. > > The demo in this article only passes url, so cMapUrl is null, condition 2 is not met, and therefore useWorkerFetch = false: the main-thread path is taken, with DOMBinaryDataFactory downloading on the main thread, and the worker requesting data from the main thread via FetchBinaryData messages when needed. Lines 338: Set the main-thread log level setVerbosityLevel(verbosity); // verbosity is undefined verbosity is undefined, so the main-thread log level stays at its default. Note that this only affects the main thread; the worker thread's log level is passed separately when the worker is created. Lines 342-350: Instantiate the three factories const transportFactory = { canvasFactory: new DOMCanvasFactory({ ownerDocument: document, enableHWA: false }), filterFactory: new DOMFilterFactory({ docId: 'd0', ownerDocument: document }), binaryDataFactory: new DOMBinaryDataFactory({ cMapUrl: null, standardFontDataUrl: null, wasmUrl: null, }), }; // binaryDataFactory: null when MOZCENTRAL or useWorkerFetch is true The three Factory classes selected earlier are instantiated and packaged as transportFactory, which is then handed to the transport. Since useWorkerFetch = false, binaryDataFactory is not null — echoing the Q&A in the previous section: built-in static assets are downloaded on the main thread. Instantiate DOMCanvasFactory: view source Instantiate DOMFilterFactory: view source Instantiate DOMBinaryDataFactory: view source Lines 352-360: Create the worker worker = PDFWorker.create({ verbosity: undefined, port: undefined }); // port comes from GlobalWorkerOptions.workerPort, which the demo does not set task._worker = worker; The demo does not pass a worker, so PDF.js creates and owns one itself. PDFWorker.create synchronously returns a PDFWorker instance; its constructor reads GlobalWorkerOptions.workerSrc (the worker file URL configured in the first article), creates a Worker, and completes the startup handshake through ready / test messages — covered in detail in Article 8. For now, just remember: worker.promise is a Promise<void> that resolves (to undefined) once the test reply succeeds, and rejects if startup fails or the worker is destroyed. Lines 362-402: Assemble the two parameter packages The first is docParams, which will later be sent across threads to the worker: const docParams = { docId: 'd0', apiVersion: '6.3.289', // build version number data: null, password: null, disableAutoFetch: false, rangeChunkSize: 65536, docBaseUrl: null, enableXfa: false, evaluatorOptions: { maxImageSize: -1, disableFontFace: false, ignoreErrors: true, isOffscreenCanvasSupported: true, isImageDecoderSupported: true, canvasMaxAreaInBytes: -1, fontExtraProperties: false, useSystemFonts: true, useWasm: true, useWorkerFetch: false, cMapUrl: null, cMapPacked: true, iccUrl: null, standardFontDataUrl: null, wasmUrl: null, hasGPU: false, // placeholder, backfilled later }, }; The second is transportParams, which stays on the main thread: const transportParams = { ownerDocument: document, pdfBug: false, styleElement: null, enableHWA: false, loadingParams: { disableAutoFetch: false, enableXfa: false }, }; One sentence to distinguish them: docParams is the "work order" sent to the worker, while transportParams is the "local configuration" kept on the main thread. Lines 404-473: The handshake after the worker is ready Promise.all([worker.promise, gpuPromise]).then(...) Once both worker readiness and GPU probing (resolving to false) complete, the following happens in sequence: 1. hasGPU = false is backfilled into evaluatorOptions; 2. A GetDocRequest message is sent to the worker with docParams as the payload. Since data is null, there is no buffer whose ownership needs to be transferred; 3. Choose the network stream: with data being null and rangeTransport being null, the url branch is taken and a network stream is instantiated (the fetch implementation is preferred, with a fallback to XHR when unsupported; see Article 9 for how range requests are issued internally): networkStream = new NetworkStream({ url, httpHeaders: null, withCredentials: false, rangeChunkSize: 65536, disableRange: false, disableStream: false, }); NetworkStream here is not a fixed imported class; it is picked at runtime inside this branch: const NetworkStream = getNetworkStream(url); // api.js#L431 getNetworkStream makes a three-way choice: function getNetworkStream(url) { // eslint-disable-next-line no-nested-ternary return isValidFetchUrl(url) ? PDFFetchStream : typeof PDFJSDev !== 'undefined' && PDFJSDev.test('GENERIC') && isNodeJS ? PDFNodeStream : PDFNetworkStream; } | Condition | Stream implementation | | --- | --- | | isValidFetchUrl(url): protocol is http / https | PDFFetchStream | | Otherwise, GENERIC build running in Node | PDFNodeStream | | Everything else | PDFNetworkStream (XHR) | The demo's url is https://..., so it actually instantiates PDFFetchStream. The other inputs have their own streams too: rangeTransport uses PDFDataTransportStream, while data means the whole PDF is already in memory and no stream is created at all. In summary: const NetworkStream = PDFFetchStream. Execution continues: 1. Receive the worker's acknowledgment of GetDocRequest (workerId): click to view the event. The value returned here is d0_worker, assigned to the workerId variable 2. Create a MessageHandler with { docId: 'd0', workerId: 'd0_worker', port: Worker }: click to view the call, assigned to the messageHandler variable 3. Create a WorkerTransport with { messageHandler, task, networkStream, transportParams, transportFactory, pagesMapper }: click to view the call, attached to task._transport 4. Send a Ready message to the worker, declaring the main-thread side ready: click to view the event At the end of the chain, task._setupCapability.resolve is called to settle the task's asynchronous setup. Lines 474-475: Return value All of the above .then chain proceeds asynchronously in the background, while getDocument itself returns task synchronously. If any step in the chain fails, the capability inside task is rejected — the very promise that the demo waits on with await loadingTask.promise. At this point, getDocument has finished executing. Summary In this section we followed the demo through the complete getDocument call: 1. Create PDFDocumentLoadingTask and extract docId = 'd0'; 2. Expand src into 35+ local variables; the demo only passes url, and everything else falls back to defaults; 3. Instantiate the three DOM factories and package them as transportFactory; 4. PDFWorker.create creates the worker and starts the handshake; 5. Assemble docParams (sent to the worker) and transportParams (kept on the main thread); 6. Once the worker is ready, send GetDocRequest, set up the network stream, create MessageHandler and WorkerTransport, and finally send Ready; 7. Return task synchronously; the demo waits for the document to be ready via await loadingTask.promise. Back to the demo in the first article: lines 4-7 call exactly the getDocument dissected here. Following the caller chain upward, the next call site is: getPage --- About me: Creator of StreamPDF, focused on research and development in AI and PDF. Original article: streampdf.com
PDF Reader in JavaScript
such a great tool mozilla/pdf.js
PDF.js Deep Dive 3: getViewport & render Introduction This is the third article in the PDF.js Deep Dive series. In the previous article, we explored the Display layer's getDocument and getPage methods. In this article, we dive into the getViewport and render methods exposed by the Display layer. All code snippets in this article are based on PDF.js v6.3.289. - PDF.js Deep Dive 1: The Architecture of PDF.js - PDF.js Deep Dive 2: getDocument & getPage getViewport For page.getViewport, the call lands on the PDFPageProxy.getViewport method. /** * @param {GetViewportParameters} params - Viewport parameters. * @returns {PageViewport} Contains 'width' and 'height' properties * along with transforms required for rendering. */ getViewport({ scale, rotation = this.rotate, offsetX = 0, offsetY = 0, dontFlip = false, } = {}) { return new PageViewport({ viewBox: this.view, userUnit: this.userUnit, scale, rotation, offsetX, offsetY, dontFlip, }); } which instantiates the PageViewport class. Entering PageViewport // Source: src/display/page_viewport.js /** * @param {PageViewportParameters} */ constructor({ viewBox, userUnit, scale, rotation, offsetX = 0, offsetY = 0, dontFlip = false, }) { this.viewBox = viewBox; this.userUnit = userUnit; this.scale = scale; this.rotation = rotation; this.offsetX = offsetX; this.offsetY = offsetY; scale *= userUnit; // Take the userUnit into account. // creating transform to convert pdf coordinate system to the normal // canvas like coordinates taking in account scale and rotation const centerX = (viewBox[2] + viewBox[0]) / 2; const centerY = (viewBox[3] + viewBox[1]) / 2; let rotateA, rotateB, rotateC, rotateD; // Normalize the rotation, by clamping it to the [0, 360) range. rotation %= 360; if (rotation < 0) { rotation += 360; } switch (rotation) { case 180: rotateA = -1; rotateB = 0; rotateC = 0; rotateD = 1; break; case 90: rotateA = 0; rotateB = 1; rotateC = 1; rotateD = 0; break; case 270: rotateA = 0; rotateB = -1; rotateC = -1; rotateD = 0; break; case 0: rotateA = 1; rotateB = 0; rotateC = 0; rotateD = -1; break; default: throw new Error( "PageViewport: Invalid rotation, must be a multiple of 90 degrees." ); } if (dontFlip) { rotateC = -rotateC; rotateD = -rotateD; } let offsetCanvasX, offsetCanvasY; let width, height; if (rotateA === 0) { offsetCanvasX = Math.abs(centerY - viewBox[1]) * scale + offsetX; offsetCanvasY = Math.abs(centerX - viewBox[0]) * scale + offsetY; width = (viewBox[3] - viewBox[1]) * scale; height = (viewBox[2] - viewBox[0]) * scale; } else { offsetCanvasX = Math.abs(centerX - viewBox[0]) * scale + offsetX; offsetCanvasY = Math.abs(centerY - viewBox[1]) * scale + offsetY; width = (viewBox[2] - viewBox[0]) * scale; height = (viewBox[3] - viewBox[1]) * scale; } // creating transform for the following operations: // translate(-centerX, -centerY), rotate and flip vertically, // scale, and translate(offsetCanvasX, offsetCanvasY) this.transform = [ rotateA * scale, rotateB * scale, rotateC * scale, rotateD * scale, offsetCanvasX - rotateA * scale * centerX - rotateC * scale * centerY, offsetCanvasY - rotateB * scale * centerX - rotateD * scale * centerY, ]; this.width = width; this.height = height; } It computes the rotation, the scale, the offsets, and the viewport dimensions after flipping — the page's width and height. Let's zoom in on the PDF point and the CSS px units. In PDF, 72 points equal one inch; in CSS, 96 px equal one inch. That means rendering at 100% size on the web requires scaling PDF points up by 96/72. PDF's coordinate system has its origin at the bottom-left corner, with the X axis pointing right and the Y axis pointing up. CSS's coordinate system has its origin at the top-left corner, with the X axis pointing right and the Y axis pointing down. So for an unrotated PDF, we need to flip the PDF's Y axis to match the web canvas coordinate system. That's why you see case 0: rotateA = 1; rotateB = 0; rotateC = 0; rotateD = -1; the rotateD of -1 in there — it exists precisely to flip the Y axis. Flip demo: get-viewport-demo MDN canvas transform render For page.render, the call lands on the PDFPageProxy.render method. /** * Begins the process of rendering a page to the desired context. * * @param {RenderParameters} params - Page render parameters. * @returns {RenderTask} An object that contains a promise that is * resolved when the page finishes rendering. */ render({ canvasContext, canvas = canvasContext.canvas, viewport, intent = "display", annotationMode = AnnotationMode.ENABLE, transform = null, background = null, optionalContentConfigPromise = null, annotationCanvasMap = null, pageColors = null, printAnnotationStorage = null, isEditing = false, recordImages = false, recordOperations = false, operationsFilter = null, }) { this._stats?.time("Overall"); const intentArgs = this._transport.getRenderingIntent( intent, annotationMode, printAnnotationStorage, isEditing ); const { renderingIntent, cacheKey } = intentArgs; // If there was a pending destroy, cancel it so no cleanup happens during // this call to render. this.#pendingCleanup = false; optionalContentConfigPromise ||= this._transport.getOptionalContentConfig(renderingIntent); const intentState = this._intentStates.getOrInsertComputed( cacheKey, makeObj ); // Ensure that a pending `streamReader` cancel timeout is always aborted. if (intentState.streamReaderCancelTimeout) { clearTimeout(intentState.streamReaderCancelTimeout); intentState.streamReaderCancelTimeout = null; } const intentPrint = !!(renderingIntent & RenderingIntentFlag.PRINT); // If there's no displayReadyCapability yet, then the operatorList // was never requested before. Make the request and create the promise. if (!intentState.displayReadyCapability) { intentState.displayReadyCapability = Promise.withResolvers(); intentState.operatorList = { fnArray: [], argsArray: [], lastChunk: false, separateAnnots: null, }; this._stats?.time("Page Request"); this._pumpOperatorList(intentArgs); } const recordForDebugger = !!( this._pdfBug && globalThis.StepperManager?.enabled ); const shouldRecordOperations = !!canvas && !this.recordedBBoxes && (recordOperations || recordForDebugger); const shouldRecordImages = !!canvas && !this.imageCoordinates && recordImages; const complete = error => { intentState.renderTasks.delete(internalRenderTask); if (shouldRecordOperations) { const recordedBBoxes = internalRenderTask.gfx?.dependencyTracker.take(); if (recordedBBoxes) { internalRenderTask.stepper?.setOperatorBBoxes( recordedBBoxes, internalRenderTask.gfx.dependencyTracker.takeDebugMetadata() ); if (recordOperations) { this.recordedBBoxes = recordedBBoxes; } } } if (shouldRecordImages && !error) { this.imageCoordinates = internalRenderTask.gfx?.imagesTracker.take(); } // Attempt to reduce memory usage during *printing*, by always running // cleanup immediately once rendering has finished. if (intentPrint) { this.#pendingCleanup = true; } this.#tryCleanup(); if (error) { internalRenderTask.capability.reject(error); this._abortOperatorList({ intentState, reason: error instanceof Error ? error : new Error(error), }); } else { internalRenderTask.capability.resolve(); } if (this._stats) { this._stats.timeEnd("Rendering"); this._stats.timeEnd("Overall"); if (globalThis.Stats?.enabled) { globalThis.Stats.add(this.pageNumber, this._stats); } } }; let dependencyTracker = null; let bboxTracker = null; if (shouldRecordOperations || shouldRecordImages) { bboxTracker = new CanvasBBoxTracker( canvas, intentState.operatorList.length ); } if (shouldRecordOperations) { dependencyTracker = new CanvasDependencyTracker( bboxTracker, recordForDebugger ); } const internalRenderTask = new InternalRenderTask({ callback: complete, // Only include the required properties, and *not* the entire object. params: { canvas, canvasContext, dependencyTracker: dependencyTracker ?? bboxTracker, imagesTracker: shouldRecordImages ? new CanvasImagesTracker(canvas) : null, viewport, transform, background, }, objs: this.objs, commonObjs: this.commonObjs, annotationCanvasMap, operatorList: intentState.operatorList, pageIndex: this._pageIndex, canvasFactory: this._transport.canvasFactory, filterFactory: this._transport.filterFactory, useRequestAnimationFrame: !intentPrint, pdfBug: this._pdfBug, pageColors, enableHWA: this._transport.enableHWA, operationsFilter, }); (intentState.renderTasks ||= new Set()).add(internalRenderTask); const renderTask = internalRenderTask.task; Promise.all([ intentState.displayReadyCapability.promise, optionalContentConfigPromise, ]) .then(([transparency, optionalContentConfig]) => { if (this.destroyed) { complete(); return; } this._stats?.time("Rendering"); if (!(optionalContentConfig.renderingIntent & renderingIntent)) { throw new Error( "Must use the same `intent`-argument when calling the `PDFPageProxy.render` " + "and `PDFDocumentProxy.getOptionalContentConfig` methods." ); } internalRenderTask.initializeGraphics({ transparency, optionalContentConfig, }); internalRenderTask.operatorListChanged(); }) .catch(complete); return renderTask; } This is a very complex method — let's break it down into chunks. Step 1: call getRenderingIntent to compute the rendering intent. There are three intents: any, display, and print. Step 2: call this._intentStates.getOrInsertComputed to get or insert the computed intent state, keyed by the rendering intent from step 1. Step 3: fetch the page's operator list. // If there's no displayReadyCapability yet, then the operatorList // was never requested before. Make the request and create the promise. if (!intentState.displayReadyCapability) { intentState.displayReadyCapability = Promise.withResolvers(); intentState.operatorList = { fnArray: [], argsArray: [], lastChunk: false, separateAnnots: null, }; this._stats?.time('Page Request'); this._pumpOperatorList(intentArgs); } There is a cache layer here: it checks whether intentState.displayReadyCapability exists. If it doesn't, a promise is created and the page's operator list is requested. > Q: What is intentState.displayReadyCapability? And why must we wait for this promise to resolve before we can actually render anything to the canvas? > > A: It is the { promise, resolve, reject } returned by Promise.withResolvers(). Its promise resolves with the boolean transparency (whether the page contains blend modes, which decides whether rendering needs an alpha canvas). > > We must wait for it to resolve for two core reasons. First, transparency is a required parameter of initializeGraphics — CanvasGraphics needs to know whether to enable the alpha channel to correctly handle blend modes, and this value can only be determined after the worker has parsed the page's content stream and resources. Second, it is essentially the "page ready" startup signal from the worker: the worker sends the StartRenderPage message before running getOperatorList, and the Display layer resolves it in _startRenderPage. Until this point the operatorList is still empty; chunks are only streamed in afterwards via _renderPageChunk, so the render pipeline must wait for this signal before it can start consuming them. > > Note that it means "page resources are ready, ready to start receiving operator chunks", not "the operator list has been fully delivered" — whether everything has arrived is marked by intentState.operatorList.lastChunk. > > Additionally, its existence doubles as a cache marker: as long as it already exists, the operator list for this rendering intent (cacheKey) has been requested before, so _pumpOperatorList doesn't need to be called again. Step 4: instantiate the InternalRenderTask class and add it to the render task queue. Step 5: wait for intentState.displayReadyCapability to resolve. Then initialize the graphics context with initializeGraphics, and notify that the operator list has changed with operatorListChanged — kicking off the actual rendering logic. With the overall flow in place, let's analyze the execution logic of each step in detail. Step 1: getRenderingIntent, computing the rendering intent Entering PDFPageProxy.render → WorkerTransport.getRenderingIntent // Source: src/display/api.js getRenderingIntent( intent, annotationMode = AnnotationMode.ENABLE, printAnnotationStorage = null, isEditing = false, isOpList = false ) { let renderingIntent = RenderingIntentFlag.DISPLAY; // Default value. let annotationStorageSerializable = SerializableEmpty; switch (intent) { case "any": renderingIntent = RenderingIntentFlag.ANY; break; case "display": break; case "print": renderingIntent = RenderingIntentFlag.PRINT; break; default: warn(`getRenderingIntent - invalid intent: ${intent}`); } const annotationStorage = renderingIntent & RenderingIntentFlag.PRINT && printAnnotationStorage instanceof PrintAnnotationStorage ? printAnnotationStorage : this.annotationStorage; switch (annotationMode) { case AnnotationMode.DISABLE: renderingIntent += RenderingIntentFlag.ANNOTATIONS_DISABLE; break; case AnnotationMode.ENABLE: break; case AnnotationMode.ENABLE_FORMS: renderingIntent += RenderingIntentFlag.ANNOTATIONS_FORMS; break; case AnnotationMode.ENABLE_STORAGE: renderingIntent += RenderingIntentFlag.ANNOTATIONS_STORAGE; annotationStorageSerializable = annotationStorage.serializable; break; default: warn(`getRenderingIntent - invalid annotationMode: ${annotationMode}`); } if (isEditing) { renderingIntent += RenderingIntentFlag.IS_EDITING; } if (isOpList) { renderingIntent += RenderingIntentFlag.OPLIST; } const { ids: modifiedIds, hash: modifiedIdsHash } = annotationStorage.modifiedIds; const cacheKeyBuf = [ renderingIntent, annotationStorageSerializable.hash, modifiedIdsHash, ]; return { renderingIntent, cacheKey: cacheKeyBuf.join("_"), annotationStorageSerializable, modifiedIds, }; } There are three modes: any, display, and print. - any is for getOperatorList / getTextContent and other "any" scenarios. - display renders the page. - print prints the page. Plus the annotationMode option, which controls whether annotations are rendered. And the isEditing editing mode. Step 2: this._intentStates.getOrInsertComputed this._intentStates is a Map that caches data keyed by the rendering intent. makeObj is a utility function: const makeObj = () => Object.create(null); getOrInsertComputed is a fairly new function in the JS spec: if the key exists, it returns the corresponding value; if it doesn't, it creates a value via the second-argument callback and returns it. See MDN. Step 3: fetching the page's operator list Here we check whether intentState.displayReadyCapability exists. If it does, the data was requested before and there's no need to call _pumpOperatorList again. Otherwise we call _pumpOperatorList to request the page's operator list. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList // Source: src/display/api.js _pumpOperatorList({ renderingIntent, cacheKey, annotationStorageSerializable, modifiedIds, }) { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { assert( Number.isInteger(renderingIntent) && renderingIntent > 0, '_pumpOperatorList: Expected valid "renderingIntent" argument.' ); } const { map, transfer } = annotationStorageSerializable; const readableStream = this._transport.messageHandler.sendWithStream( "GetOperatorList", { pageId: this.#pagesMapper.getPageId(this._pageIndex + 1) - 1, pageIndex: this._pageIndex, intent: renderingIntent, cacheKey, annotationStorage: map, modifiedIds, }, /* queueingStrategy = */ undefined, transfer ); const reader = readableStream.getReader(); const intentState = this._intentStates.get(cacheKey); intentState.streamReader = reader; const pump = () => { reader.read().then( ({ value, done }) => { if (done) { intentState.streamReader = null; return; } if (this._transport.destroyed) { return; // Ignore any pending requests if the worker was terminated. } this._renderPageChunk(value, intentState); pump(); }, reason => { intentState.streamReader = null; if (this._transport.destroyed) { return; // Ignore any pending requests if the worker was terminated. } if (intentState.operatorList) { // Mark operator list as complete. intentState.operatorList.lastChunk = true; for (const internalRenderTask of intentState.renderTasks) { internalRenderTask.operatorListChanged(); } this.#tryCleanup(); } if (intentState.displayReadyCapability) { intentState.displayReadyCapability.reject(reason); } else if (intentState.opListReadCapability) { intentState.opListReadCapability.reject(reason); } else { throw reason; } } ); }; pump(); } Here, through the transport, we send an important streaming event to the worker: GetOperatorList. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList handler.on( 'GetOperatorList', function ({ pageId, pageIndex, intent, cacheKey, annotationStorage, modifiedIds }, sink) { pdfManager.getPage(pageId).then(function (page) { const task = new WorkerTask(`GetOperatorList: page ${pageIndex}`); startWorkerTask(task); // NOTE: Keep this condition in sync with the `info` helper function. const start = verbosity >= VerbosityLevel.INFOS ? Date.now() : 0; // Pre compile the pdf page and fetch the fonts/images. page .getOperatorList({ handler, sink, task, intent, cacheKey, annotationStorage, modifiedIds, pageIndex, }) .then( opListInfo => { if (start) { info(`${task.name}; time=${Date.now() - start}ms, len=${opListInfo.length}`); } sink.close(); }, reason => { if (task.terminated) { return; // ignoring errors from the terminated thread } sink.error(reason); }, ) .finally(() => { finishWorkerTask(task); }); }); }, ); It calls pdfManager.getPage to get the Page instance. This call points to NetworkPDFManager.getPage, which in turn points to BasePdfManager.getPage. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.getPage (parent BasePdfManager.getPage) getPage(pageIndex) { return this.pdfDocument.getPage(pageIndex); } which calls this.pdfDocument.getPage. Let's first see where this.pdfDocument comes from. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.constructor this.pdfDocument = new PDFDocument(this, this.streamManager.getStream()); instantiating PDFDocument. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.constructor → PDFDocument.constructor constructor(pdfManager, stream) { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { assert( stream instanceof BaseStream, 'PDFDocument: Invalid "stream" argument.' ); } if (stream.length <= 0) { throw new InvalidPDFException( "The PDF file is empty, i.e. its size is zero bytes." ); } this.pdfManager = pdfManager; this.stream = stream; this.xref = new XRef(stream, pdfManager); const idCounters = { font: 0, }; this._globalIdFactory = class { static getDocId() { return `g_${pdfManager.docId}`; } static createFontId() { return `f${++idCounters.font}`; } static createObjId() { unreachable("Abstract method `createObjId` called."); } static getPageObjId() { unreachable("Abstract method `getPageObjId` called."); } }; } Here, an XRef instance is created to parse the PDF file's XRef table. > Q: What is the XRef table? > > A: The XRef (Cross-Reference) table is the PDF file's "object directory". Every entity inside a PDF — pages, fonts, images, content streams, etc. — is a numbered object (e.g. 3 0 obj). The XRef table records the byte offset of each object within the file. > > For example: > > > ``` > > xref > > 0 5 > > 0000000000 65535 f > > 0000000010 00000 n > > 0000000053 00000 n > > 0000000102 00000 n > > 0000000173 00000 n > > ``` > > The first line, 0 5, means the next 5 entries list the objects starting from object 0. Each entry is exactly 20 bytes: the first 10 digits are the byte offset, the middle 5 digits are the generation number, and the last one is n (in-use) or f (free/deleted). > > When the parser gets an object reference (say 2 0 R, meaning "reference object 2"), it looks up the XRef table and computes the object's location in the file directly, instead of scanning the file from the start like XML/JSON. This is also the key to PDF's incremental updates (append-only): after each append-only modification, you only need to append a new XRef section and point the startxref pointer at the end of the file to the latest one. > > In pdf.js, the XRef class parses this table, maintains the {object number → offset} mapping, and resolves the actual content of objects (when it hits an indirect reference like 2 0 R, it recursively parses until it gets the real value). > This is one of the optimization points in StreamPDF: the XRef table is pre-parsed on the server side. We won't expand on it here — a dedicated article will introduce it in detail later. We won't go deeper for now — back to rendering. A later article will explore PDF.js's lexical and syntactic parsing of PDF documents. Now that PDFDocument is initialized, let's return to the getPage method. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.getPage → PDFDocument.getPage getPage(pageIndex) { const cachedPromise = this.#pagePromises.get(pageIndex); if (cachedPromise) { return cachedPromise; } const { catalog, linearization, xfaFactory } = this; let promise; if (xfaFactory) { promise = Promise.resolve([Dict.empty, null]); } else if (linearization?.pageFirst === pageIndex) { promise = this.#getLinearizationPage(pageIndex); } else { promise = catalog.getPageDict(pageIndex); } promise = promise.then( ([pageDict, ref]) => new Page({ pdfManager: this.pdfManager, xref: this.xref, pageIndex, pageDict, ref, globalIdFactory: this._globalIdFactory, fontCache: catalog.fontCache, builtInCMapCache: catalog.builtInCMapCache, standardFontDataCache: catalog.standardFontDataCache, globalColorSpaceCache: catalog.globalColorSpaceCache, globalImageCache: catalog.globalImageCache, systemFontCache: catalog.systemFontCache, nonBlendModesSet: catalog.nonBlendModesSet, xfaFactory, }) ); this.#pagePromises.set(pageIndex, promise); return promise; } It reads the catalog, checks linearization, and creates the page. > Q: What is the catalog? > A: The Catalog is the PDF file's "root dictionary", sitting at the top of the object graph. The /Root entry in the trailer at the end of the file points to it — it's the entry point of the entire PDF. It holds references to all other top-level structures: most importantly /Pages (the root of the page tree), plus /Names (named objects), /Outlines (bookmarks), /AcroForm (forms), /Metadata (XMP metadata), and more. > > In pdf.js, the Catalog class wraps this root dictionary and provides caches shared across pages — the font cache (fontCache), the global color space cache (globalColorSpaceCache), the image cache (globalImageCache), the built-in CMap cache (builtInCMapCache), etc. These caches are reused across pages to avoid re-parsing the same font definitions or color spaces — which is why the Page constructor above takes that long list of catalog.fontCache, catalog.globalColorSpaceCache, and so on. > Q: What is linearization? > A: Linearization is a "fast web view" optimization provided by the PDF specification. > > A normal PDF is parsed like this: jump to the end of the file and read startxref → read the trailer → find the main XRef section from the trailer → only then can objects be located. That means you need the tail of the file before you can start working. > > A linearized PDF additionally places a Linearized dictionary at the beginning of the file (at object 1), which records: > > - N — total page count > - P — the index of the first page (default 0) > - O — the object number of the first page > - T — the offset of the main XRef section in the file > - H — the hint arrays, telling the reader which pages live where in the file > > With just a few hundred bytes from the start of the file, the parser can use this dictionary to skip the main XRef and jump straight to the first page's data to start rendering, without waiting for the whole file to download. The effect is dramatic on large files (tens-of-MB scanned documents). > > In pdf.js, the PDFDocument.linearization getter tries to parse the beginning of the file; if it finds this Linearized dictionary, it returns an object with fields like numPages, pageFirst, and objectNumberFirst. The getPage logic above is: if the PDF is linearized and the requested page is the first page, take the #getLinearizationPage fast path and use linearization.objectNumberFirst to get the first page's dictionary directly; otherwise fall back to the standard catalog lookup. > But linearization depends heavily on the tool that produced the file — most PDFs have never been linearized. We've now touched some real PDF format specifications. This returns the Page instance — let's stay restrained and not dig deeper. Back to PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList Continuing: // Pre compile the pdf page and fetch the fonts/images. page .getOperatorList({ handler, sink, task, intent, cacheKey, annotationStorage, modifiedIds, pageIndex, }) .then( opListInfo => { if (start) { info(`${task.name}; time=${Date.now() - start}ms, len=${opListInfo.length}`); } sink.close(); }, reason => { if (task.terminated) { return; // ignoring errors from the terminated thread } sink.error(reason); }, ) .finally(() => { finishWorkerTask(task); }); It calls the Page.getOperatorList method to get the page's rendering operators. Let's take a quick look at the implementation of Page.getOperatorList. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → Page.getOperatorList // Source: src/core/document.js async getOperatorList({ handler, sink, task, intent, cacheKey, pageIndex = this.pageIndex, annotationStorage = null, modifiedIds = null, }) { const contentStreamPromise = this.getContentStream(); const resourcesPromise = this.loadResources(RESOURCES_KEYS_OPERATOR_LIST); // ... const pageListPromise = Promise.all([ contentStreamPromise, resourcesPromise, ]).then(async ([contentStream]) => { const resources = await this.#getMergedResources( contentStream.dict, RESOURCES_KEYS_OPERATOR_LIST ); const opList = new OperatorList(intent, sink); handler.send("StartRenderPage", { transparency: partialEvaluator.hasBlendModes( resources, this.nonBlendModesSet ), pageIndex, cacheKey, }); await partialEvaluator.getOperatorList({ stream: contentStream, task, resources, operatorList: opList, }); return opList; }); // ... A lot of logic is omitted — but did you spot the key handler.send("StartRenderPage")? This event corresponds to the intentState.displayReadyCapability we saw earlier. The main thread's StartRenderPage event listener: Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → Page.getOperatorList → Main: on StartRenderPage messageHandler.on('StartRenderPage', data => { if (this.destroyed) { return; // Ignore any pending requests if the worker was terminated. } const page = this.#pageCache.get(data.pageIndex); page._startRenderPage(data.transparency, data.cacheKey); }); which calls page._startRenderPage. /** * @private */ _startRenderPage(transparency, cacheKey) { const intentState = this._intentStates.get(cacheKey); if (!intentState) { return; // Rendering was cancelled. } this._stats?.timeEnd("Page Request"); // TODO Refactor RenderPageRequest to separate rendering // and operator list logic intentState.displayReadyCapability?.resolve(transparency); } This method resolves the intentState.displayReadyCapability promise. OK, let's return to the _pumpOperatorList method. Back to PDFPageProxy.render → PDFPageProxy._pumpOperatorList Continuing with the streaming data: through the sink, data keeps flowing back to the reader on the main thread. intentState.streamReader = reader; const pump = () => { reader.read().then( ({ value, done }) => { if (done) { intentState.streamReader = null; return; } if (this._transport.destroyed) { return; // Ignore any pending requests if the worker was terminated. } this._renderPageChunk(value, intentState); pump(); }, reason => { intentState.streamReader = null; if (this._transport.destroyed) { return; // Ignore any pending requests if the worker was terminated. } if (intentState.operatorList) { // Mark operator list as complete. intentState.operatorList.lastChunk = true; for (const internalRenderTask of intentState.renderTasks) { internalRenderTask.operatorListChanged(); } this.#tryCleanup(); } if (intentState.displayReadyCapability) { intentState.displayReadyCapability.reject(reason); } else if (intentState.opListReadCapability) { intentState.opListReadCapability.reject(reason); } else { throw reason; } }, ); }; pump(); Every time the worker pushes a chunk into the stream, it's consumed here, which calls this._renderPageChunk(value, intentState); Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk _renderPageChunk(operatorListChunk, intentState) { // Add the new chunk to the current operator list. for (let i = 0, ii = operatorListChunk.length; i < ii; i++) { intentState.operatorList.fnArray.push(operatorListChunk.fnArray[i]); intentState.operatorList.argsArray.push(operatorListChunk.argsArray[i]); } intentState.operatorList.lastChunk = operatorListChunk.lastChunk; intentState.operatorList.separateAnnots = operatorListChunk.separateAnnots; // Notify all the rendering tasks there are more operators to be consumed. for (const internalRenderTask of intentState.renderTasks) { internalRenderTask.operatorListChanged(); } if (operatorListChunk.lastChunk) { this.#tryCleanup(); } } After pre-processing the data, it calls internalRenderTask.operatorListChanged() to notify the render tasks that new operators have arrived. Step 4: the render tasks have new operators Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged operatorListChanged() { if (!this.graphicsReady) { this.graphicsReadyCallback ||= this._continueBound; return; } this.gfx.dependencyTracker?.growOperationsCount( this.operatorList.fnArray.length ); this.stepper?.updateOperatorList(this.operatorList); if (this.running) { return; } this._continue(); } which goes to _continue. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue _continue() { this.running = true; if (this.cancelled) { return; } if (this.task.onContinue) { this.task.onContinue(this._scheduleNextBound); } else { this._scheduleNext(); } } which goes to _scheduleNext. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue → internalRenderTask._scheduleNext _scheduleNext() { if (this._useRequestAnimationFrame) { this.#rAF = window.requestAnimationFrame(() => { this.#rAF = null; this._nextBound().catch(this._cancelBound); }); } else { Promise.resolve().then(this._nextBound).catch(this._cancelBound); } } Via window.requestAnimationFrame, it schedules the _nextBound method. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue → internalRenderTask._scheduleNext → internalRenderTask._nextBound In the constructor, this._nextBound = this._next.bind(this); which goes to the _next method. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue → internalRenderTask._scheduleNext → internalRenderTask._nextBound → internalRenderTask._next async _next() { if (this.cancelled) { return; } this.operatorListIdx = this.gfx.executeOperatorList( this.operatorList, this.operatorListIdx, this._continueBound, this.stepper, this._operationsFilter ); if (this.operatorListIdx === this.operatorList.argsArray.length) { this.running = false; if (this.operatorList.lastChunk) { this.gfx.endDrawing(); InternalRenderTask.#canvasInUse.delete(this._canvas); this.callback(); } } } It calls this.gfx.executeOperatorList — the operator list is now actually executed. Step 5: initializeGraphics & operatorListChanged Once intentState.displayReadyCapability.promise is ready, the graphics context is initialized. Entering initializeGraphics // Source: src/display/api.js (InternalRenderTask) initializeGraphics({ transparency = false, optionalContentConfig }) { if (this.cancelled) { return; } if (this._canvas) { if (InternalRenderTask.#canvasInUse.has(this._canvas)) { throw new Error( "Cannot use the same canvas during multiple render() operations. " + "Use different canvas or ensure previous operations were " + "cancelled or completed." ); } InternalRenderTask.#canvasInUse.add(this._canvas); } if (this._pdfBug && globalThis.StepperManager?.enabled) { this.stepper = globalThis.StepperManager.create(this._pageIndex); this.stepper.init(this.operatorList); this.stepper.nextBreakPoint = this.stepper.getNextBreakPoint(); } const { viewport, transform, background, dependencyTracker, imagesTracker, } = this.params; // When printing in Firefox, we get a specific context in mozPrintCallback // which cannot be created from the canvas itself. const canvasContext = this._canvasContext || this._canvas.getContext("2d", { alpha: false, willReadFrequently: !this._enableHWA, }); this.gfx = new CanvasGraphics( canvasContext, this.commonObjs, this.objs, this.canvasFactory, this.filterFactory, { optionalContentConfig }, this.annotationCanvasMap, this.pageColors, dependencyTracker, imagesTracker ); this.gfx.beginDrawing({ transform, viewport, transparency, background, }); this.operatorListIdx = 0; this.graphicsReady = true; this.graphicsReadyCallback?.(); } By setting this.graphicsReady = true, the internalRenderTask.operatorListChanged method is allowed to proceed. It then immediately triggers operatorListChanged once more, starting execution of the operator list. operatorListChanged() { if (!this.graphicsReady) { this.graphicsReadyCallback ||= this._continueBound; return; } this.gfx.dependencyTracker?.growOperationsCount( this.operatorList.fnArray.length ); this.stepper?.updateOperatorList(this.operatorList); if (this.running) { return; } this._continue(); } Summary In this article, we explored the Display layer's getViewport and render methods. The core of getViewport is building an affine transform matrix: converting PDF's coordinate system (origin at the bottom-left, unit = point) into canvas's coordinate system (origin at the top-left, unit = px), while handling rotation, scale, and dontFlip. render is a rendering pipeline spanning the main thread and the worker: !Image description Text version: Main thread (PDFPageProxy.render) |-- getRenderingIntent compute the intent, build the cache key |-- _intentStates.getOrInsert... get or create the intent state by cache key |-- _pumpOperatorList send the streaming GetOperatorList request to the worker | `-- reader.read() loop consumes chunks, hands each to _renderPageChunk |-- new InternalRenderTask create the render task, add it to renderTasks `-- await displayReadyCapability `-- initializeGraphics create CanvasGraphics, beginDrawing `-- operatorListChanged -> _next -> gfx.executeOperatorList Worker thread (on:GetOperatorList) |-- pdfManager.getPage locate the Page `-- Page.getOperatorList |-- handler.send("StartRenderPage") resolves displayReadyCapability `-- partialEvaluator.getOperatorList parses operators, streams them back via sink Understanding this pipeline means understanding the skeleton of PDF.js rendering: the main thread only schedules and consumes operators; the real parsing work all happens in the worker. At this point, after these three articles, the rendering architecture of PDF.js is clear. Starting from the next article, we'll dive into some of the details to round out the depth. Happy reading! Next up: MessageHandler — a deep dive into the core mechanism of message communication.
12 Free Browser-Based PDF Tools for Developers (No Upload, No Sign-Up) I build DonePDF, a free site with 90+ PDF tools. Most of them run entirely in the browser, which turned out to be handy for developer work: no upload, no account, and the file never leaves your machine. Here are the ones I find most useful when PDFs show up in dev work, grouped by what you're trying to do. 📝 Get content out of a PDF PDF to Markdown Turns a PDF into GitHub-flavored Markdown: headings, paragraphs, lists and simple tables, with a live preview. Great for moving old docs into a repo or a wiki. PDF to JSON Text per page, document metadata and optional text coordinates. Pick the pages you need, and use OCR for scanned files. Useful when you need to feed PDF content into a script or a pipeline. PDF to HTML Either clean responsive text with headings and lists, or exact page images with selectable text on top. 🖨️ Generate PDFs Markdown to PDF README or docs to PDF with tables, task lists, code blocks, links and images. Set the page size and margins, and the text stays selectable. HTML to PDF Paste HTML or upload an .html file and get a PDF with CSS layout, web fonts, images and page breaks. Scripts in the HTML are never run. JSON to PDF Validates the JSON (with line and column on errors) and prints it as colored, pretty-printed code or as a key-path table. Handles files up to 30 MB. 🔍 Debug and inspect PDFs Compare PDF Word-level text diff between two PDFs, plus added and removed pages, with overlay, swipe and difference views. Basically git diff for PDFs. Validate PDF Is the file structure intact? Are fonts embedded? Is it scanned, encrypted, or carrying scripts? You get a report without uploading anything. Analyze PDF Text per page, word and character counts, reading time, metadata, and search with hits per page. Sanitize PDF See and remove hidden data: author and properties, XMP metadata, attachments, embedded JavaScript, comments and form data. Worth running before you publish a PDF anywhere. 🧩 Small utilities PDF to Base64 Encode a PDF as a Base64 string, optionally with the data:application/pdf;base64, prefix, for tests, fixtures or API payloads. Base64 to PDF The reverse: paste a Base64 string from a log or an API response and get the PDF back. Repair PDF Rebuilds a broken file structure and recovers the pages that can still be read, with a report of what was fixed. 🛠️ How it works under the hood The browser-based tools are built on open-source libraries: - pdf.js to read and render pages - pdf-lib to write changes back into the PDF - Fabric.js for the editing canvas in the online PDF editor - Tesseract.js for OCR on scanned pages A few heavier tools still run on the server, and their pages say so. --- What PDF task do you still do by hand or with a script you'd rather not maintain? I'm always looking for the next tool to build, so let me know in the comments. 👇
I built a bank statement to Excel converter that never uploads your PDF Most "PDF bank statement to Excel" sites want you to upload the file. Think about what's on that PDF: your name, your address, your account number, and every payment you made last month. I wouldn't hand that to a site I'd never heard of, so when I built one for my own site I set one rule before writing any code. The file never leaves the browser tab. This is how it works, including the parts that are still fragile. It's plain JavaScript, one ES module of about 230 lines plus Mozilla's pdf.js, with no backend at all. If you'd rather try it before reading, it's live as a free bank statement to Excel converter on my studio's site. Reading text out of a PDF A PDF doesn't contain lines or tables. It contains text fragments, each with a position on the page, and the viewer simply draws them where they belong. pdf.js hands you those fragments through getTextContent(), and every item carries a transform matrix whose last two values are its x and y coordinates. I self-host pdf.js (the module and its worker are about 1.7 MB together) instead of loading it from a CDN, because "nothing leaves the tab" should also mean no third party learns that you opened the tool. It's loaded with isEvalSupported: false as well, which stops pdf.js from compiling code with eval; that path was behind CVE-2024-4367, and a tool that opens files from strangers' banks has no reason to keep it on. PDF coordinates start at the bottom left, so a higher y is further up the page, hence b.y - a.y. Fragments within 2.5 units of each other vertically count as one line. That number came from trial and error: banks don't always put a date and its amount on exactly the same baseline, and with a tolerance of zero half the rows split in two. Which lines are transactions? A line counts as a transaction when it starts with a date and contains at least one money amount. Both of those turned out to be harder than they sound. The parser knows five date shapes: 2026-10-03, 03/10/2026, 3 Oct 2026, Oct 3, 2026, and a short 03/10 with no year at all. The month names include the Dutch ones (mrt, mei, okt), because I'm based in Rotterdam and wanted Dutch statements to work from day one. Then there's the classic problem. Is 03/10/2026 the 3rd of October or the 10th of March? One date can't tell you, but a whole statement usually gives it away: let dmy = 0, mdy = 0; for (const l of lines) { const m = l.cells.map((c) => c.s).join(' ').match(/^(\d{1,2})[./-](\d{1,2})[./-]\d{2,4}/); if (m) { if (+m[1] > 12) dmy++; if (+m[2] > 12) mdy++; } } A date whose first number is above 12 has to be day first, and the other way round. If a statement only covers the first twelve days of a month there's no evidence either way, and the tool falls back to looking for a dollar sign or "USD" near the top of the document. That fallback is a guess, so the detected order is shown on screen next to a dropdown to override it. Amounts are one long regex. It accepts an optional currency sign, thousands separators (comma, dot, space or apostrophe, since Swiss statements write 1'250.00), exactly two decimals and an optional trailing flag. The flag matters more than I expected, because plenty of statements never print a minus sign and put CR or DR after the number instead, or Af and Bij on Dutch ones. Requiring exactly two decimals is what keeps reference numbers and page numbers from being read as money. Debit, credit or balance? A transaction line often holds two or three amounts: the money going out or coming in, and the running balance. So the parser first looks for a header row (Debit, Withdrawals, Paid out, Bedrag af, Balance, Saldo and so on) and remembers the horizontal centre of each heading. Every amount on a transaction line then goes to the column whose heading is nearest in x. Under "Paid out" it becomes negative, under "Paid in" positive, and under "Balance" it lands in the balance column. Without a recognisable header it falls back to a cruder rule, where the first amount is the transaction and the last one is the balance. That's right more often than you'd think, and wrong often enough that every cell in the results table is editable. Two smaller rules clean up the rest. Lines like "Opening balance" or "Balance brought forward" are skipped, since to everything above they look exactly like transactions. And a line with no date and no amount, shorter than 120 characters, is appended to the previous transaction's description, which keeps descriptions that wrap onto a second line in one cell. Writing an .xlsx without a library CSV was easy, as long as you put a byte order mark in front; without it, Excel on Windows mangles every character that isn't ASCII. For a real .xlsx I didn't want a second large dependency next to pdf.js just to write a file with four columns. An .xlsx is a zip archive of XML files, and a zip doesn't have to be compressed. The "stored" method writes each file behind a small header with a CRC-32 checksum and finishes with a central directory that lists them all. My writer is about 40 lines, plus six XML files as template strings: the content types, two relationship files, the workbook, a style sheet and the sheet itself. The only part that isn't obvious is dates. Excel stores a date as a number of days since its own epoch, so converting a JavaScript date is one line: const serial = (d) => d.getTime() / 86400000 + 25569; 25569 is Excel's serial number for 1 January 1970. The value only displays as a date because the cell points at a style with the format yyyy-mm-dd; leave the style out and the cell shows something like 46298, which is correct and completely useless. The header row is frozen, the description column is wide, and descriptions are written as inline strings, so there's no shared-strings table to maintain. How do you know nothing is uploaded? You shouldn't have to take my word for it. The module reads the file with file.arrayBuffer(), parses it in memory and builds the download with URL.createObjectURL(). Nothing in it sends the file, its text or a single number anywhere. The site's Content Security Policy only allows connections back to its own domain, and if you keep the Network tab open while converting a statement, you won't see a request carrying your data. "Clear" empties the table, and nothing is written to storage at any point. What it doesn't do yet Scanned statements don't work. When a PDF is a picture of a page there's no text layer for pdf.js to read, and the tool says so instead of guessing. OCR in the browser is possible, but it would make the page a lot heavier, and I'm honestly not sure it's worth it for a free tool. There's also a bug I know about and haven't fixed. Short dates without a year take their year from the first four-digit year found anywhere in the document. So a statement running from December into January, from a bank that prints dates that way, puts the January transactions in the wrong year. It's an edge case, but it's exactly the kind that ends up in somebody's annual accounts, which is why the tool shows you every row before it lets you download. Every bank lays out its statement a little differently, so I expect to find more of these. If one of yours comes out wrong, the editable table will get you through it today, and I'd really like to hear which bank it was. --- I'm Philip, a Microsoft-certified Power BI analyst from Rotterdam. I recently launched Flippie Labs, where I build dashboards and reporting automation for B2B teams. The bigger version of this problem, where invoices, receipts and statements arrive as PDFs and need to become clean tables every month, is what my documents-to-data service is for.
I built an ATS CV checker that never uploads your CV (pdf.js, jszip and a lot of heuristics) !Image description Every job seeker eventually hears the same scary line: "an ATS rejected your CV before a human ever saw it." It's mostly not true. An applicant tracking system (ATS) is the software companies use to collect applications. It reads the text out of your CV (or résumé), drops it into a candidate profile, and lets recruiters search it. Most rejections still come from people, knockout questions on the form, or the sheer number of applicants. But there's a real problem hiding under the myth: if the ATS can't read your file properly, your details end up jumbled or missing. Your two-column layout gets read straight across, your phone number was an icon, your "Experience" heading was a picture of the word "Experience". The recruiter searching for "SQL" never finds you. I work on Litjob, an AI job application assistant, and I wanted a free tool that shows people exactly that: here's what an ATS gets out of your file. I had one hard rule. The rule: the CV never leaves the browser A CV is about the most personal document people have. Name, phone, address, work history, sometimes a photo and a date of birth. I didn't want to receive it, store it, or explain a privacy policy about it. That rule also happened to be the cheap option. No upload endpoint, no storage, no AI calls per check, no server bill that grows with traffic. The whole check is a pure function that runs on the visitor's laptop. So the plan became: 1. Read the PDF or Word file in the browser. 2. Pull out the text in the order a text extractor reads it, plus a few layout facts (where text sits on the page, images, tables, text boxes, headers). 3. Run checks on those facts and explain each problem in plain English. Reading PDFs with pdf.js pdf.js is Mozilla's PDF engine, the one inside Firefox. It's big: the worker alone is about 1 MB minified. Loading that for every visitor to a marketing page would be silly, so it only loads when someone actually picks a file: async function readPdf(file: File) { const pdfjs = await import('pdfjs-dist'); pdfjs.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url, ).toString(); const doc = await pdfjs.getDocument({ data: new Uint8Array(await file.arrayBuffer()), isEvalSupported: false, }).promise; // ... } The new URL(..., import.meta.url) trick lets the bundler (Next.js here) copy the worker file into the build and hand back its URL. I was half expecting to have to copy the worker into public/ by hand and keep its version in sync forever. I didn't. For each page, getTextContent() gives you text items in content-stream order, which is roughly what a naive extractor sees. Each item also has a transform matrix, so you know where it sits on the page. I normalise everything to fractions of the page size, so a US Letter page and an A4 page look the same to the checks: const [left, bottom, right, top] = page.view; const width = right - left; const height = top - bottom; for (const item of content.items) { if (!('str' in item)) continue; text += item.str + (item.hasEOL ? '\n' : ''); items.push({ page: pageNumber, x: (item.transform[4] - left) / width, y: 1 - (item.transform[5] - bottom) / height, // 0 = top of the page width: item.width / width, text: item.str, }); } Images come from the operator list. Count the "paint image" operators and you know how many pictures and icons are on the page: const imageOps = new Set([ pdfjs.OPS.paintImageXObject, pdfjs.OPS.paintInlineImageXObject, pdfjs.OPS.paintImageMaskXObject, pdfjs.OPS.paintImageXObjectRepeat, ]); const operators = await page.getOperatorList(); imageCount += operators.fnArray.filter((op) => imageOps.has(op)).length; The first check is the most important one, and the simplest: if a PDF gives back fewer than about 60 words, it's almost certainly a scan or a picture of a CV. To an ATS that's a blank document. I stop there and say so, because nothing else matters until that's fixed. Spotting two-column CVs (the heuristic that took the most thought) Two-column templates are everywhere, and they're the classic way to get your sidebar and your work history shuffled together line by line. The obvious idea is "lots of text starts in the middle of the page, so it's two columns." That would flag nearly every normal CV, because normal CVs are full of things that sit to the right: - right-aligned dates (Mar 2021 – Present) - indented bullet points - the second half of a long line What ended up working: for each page, look only at substantial runs of text (at least 12 characters, at least 12% of the page wide) that start between 28% and 70% of the way across. Round their start positions into buckets and find the most common one. If at least six runs start at that same spot, and at least six other runs start at the left margin and stop before it, that's a column. for (const item of pageItems) { if (item.x >= 0.28 && item.x <= 0.7 && item.width >= 0.12 && item.text.trim().length >= 12) { const bucket = Math.round(item.x * 25) / 25; middleStarts.set(bucket, (middleStarts.get(bucket) ?? 0) + 1); } } // ...find the busiest bucket, then count left-margin runs that end before it Dates are short, so they don't count. Bullets start near the left margin, so they don't count. A sidebar plus a main column does count. I tested it on printed HTML CVs (a one-column one and a sidebar one) and it got both right. It will still be wrong sometimes, which is why the result says "your CV looks like it has two columns" and then shows the extracted text so people can judge for themselves. That last part turned out to be the most useful feature of the whole tool. People don't need my verdict. They need to see their sidebar text shuffled into their job history. Icons are secretly characters This one surprised me. Lots of CV templates use an icon font for the little phone, email and location symbols. When you extract the text, those icons come out as characters from Unicode's Private Use Area (U+E000 to U+F8FF): code points with no agreed meaning, which fonts fill with whatever they like. Broken font encodings show up as the replacement character (U+FFFD) or as (cid:123) sequences. To an ATS, your phone number now starts with a meaningless character, or the email icon is glued onto the address. So the check counts them: function unreadableCharacterCount(text: string): number { return (text.match(/[\uE000-\uF8FF\uFFFD]|\(cid:\d+\)/g) ?? []).length; } Funny story: my tooling quietly turned those \uE000 escapes into the actual invisible characters when the file was first written. The code still worked, because a character class made of the real characters means the same thing, but on screen the line read /[-]/, which is a great way to confuse the next person. I only caught it while rereading the code to write this post. They're escapes again now. Letter-spaced headings are the cousin of this problem: E X P E R I E N C E looks elegant and reads as ten one-letter words. A small regex catches runs of single letters separated by spaces. Word files are zip files full of XML A .docx is a zip archive. Inside, word/document.xml holds the body, word/header1.xml and friends hold headers and footers, and docProps/app.xml usually has a page count. So: jszip to open it, the browser's own DOMParser to read the XML, and no Word library at all. const { default: JSZip } = await import('jszip'); const zip = await JSZip.loadAsync(await file.arrayBuffer()); const xml = await zip.file('word/document.xml')?.async('string'); const body = new DOMParser().parseFromString(xml, 'application/xml'); const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const tables = body.getElementsByTagNameNS(W, 'tbl').length; const textBoxes = body.getElementsByTagNameNS(W, 'txbxContent').length; Tables and text boxes are worth flagging because some ATS read tables cell by cell in an odd order, and many skip text boxes entirely. Then there's a trap that's easy to fall into. Word saves shapes and text boxes twice, on purpose: a modern version inside mc:AlternateContent > mc:Choice, and a fallback copy inside mc:Fallback for old versions of Word. Both contain the same text, so a naive count reports two text boxes where there's one, and their text appears twice. Every count needs to skip anything under a Fallback element: const MC = 'http://schemas.openxmlformats.org/markup-compatibility/2006'; function isFallbackCopy(node: Element): boolean { for (let parent = node.parentElement; parent; parent = parent.parentElement) { if (parent.namespaceURI === MC && parent.localName === 'Fallback') return true; } return false; } Headers got their own check too. Lots of people put their name, email and phone in the Word header so it repeats on every page. Some ATS ignore headers and footers. If the email only appears in the header, the tool says so. The rest of the checks are boring, which is the point After the text is out, everything else is plain string work: - Contact details: an email regex, plus a phone check that requires 9 to 15 digits, because a loose phone regex happily matches a date range like 2019 - 2021. - Headings: short lines on their own that look like "Work experience", "Education" and "Skills". If "Where I've been" is the only heading for your jobs, you get a gentle nudge. - Dates: at least a couple of Mar 2021 – Present or 03/2021 style dates, since an ATS works out your years of experience from them. - Length and file name: CV (2).pdf and resume_final.docx look like every other file in the pile. All the judging lives in one pure function, checkAtsReadability(facts), in a shared package with unit tests. The browser code only gathers facts. That split made it easy to test the awkward cases (a scanned PDF, contact details only in a header, a fake two-column page) without touching a real file. What it can't do I want to be clear about this, because the job-search internet is full of tools promising an "ATS score": - It isn't an ATS score. There's no such universal number. Every company configures its ATS differently, and no outside tool can see it. This tool tells you whether your file is readable, nothing more. - PDF tables are invisible to it. A PDF doesn't record "this is a table", only lines and text at positions, so I only check tables in Word files. - The column detector is a heuristic. It's right on my test CVs, but someone will have a layout that fools it. Showing the extracted text is the safety net. Try it, break it The tool is here: getlitjob.com. It's free, there's no sign-up, and you can open your browser's network tab and check that your file never gets uploaded. If you have a CV that it gets wrong (especially a weird layout that fools the column check), I'd genuinely like to hear about it in the comments. Heuristics only get better when someone breaks them.