PDF.js Source Walkthrough: MessageHandler
October 27, 2026 · StreamPDF Team

Introduction
This is the installment of the PDF.js source walkthrough series covering the "cross-thread message hub": src/shared/message_handler.js (538 lines).
The worker thread parses the PDF, the main thread renders to canvas — all communication between them (except Transferable zero-copy ownership transfers) goes through this single class: one MessageHandler instance on the main-thread side, another on the worker side. Both instances share the same comObj (a MessagePort or Worker object).
Earlier api.js articles deferred to it four times as "covered separately":
sendWithPromise("GetDocRequest", docParams)— the handshake channel of article 2, ongetDocument;send("Ready", null)— the declaration that closes out the handshake;sendWithStream("GetOperatorList", { ... })— the streaming operatorList of article 5, on render;sendWithStream("GetTextContent", { ... })— the streaming text blocks for the text layer.
Those caller-side views only showed "how the API is used." This article flips the perspective — standing at the protocol-implementation layer, we look at how messages are wrapped, dispatched, callback-driven, cancelled, and flow-controlled.
MessageHandler's responsibility in one sentence: on top of the minimal primitive comObj.postMessage, it builds three communication primitives with different semantics — fire-and-forget (send), request-reply (sendWithPromise), and streaming (sendWithStream).
At the end of the file there are only two exports: the MessageHandler class itself, and the wrapReason helper that must be reused to preserve Error identity across threads.
Module Overview
message_handler.js can be cut into seven blocks:
| Block | Lines | Responsibility |
|---|---|---|
CallbackKind / StreamKind constants | 26-40 | Type enums for the two message-body categories |
wrapReason | 44-74 | Preserves Error types across thread boundaries |
MessageHandler constructor | 76-93 | Four Map containers + message listener |
#onMessage dispatch main loop | 95-156 | Three-way dispatch: callback / action / stream |
| The three send APIs | 158-294 | send / sendWithPromise / sendWithStream |
Both ends of a stream: #createStreamSink + #processStreamMessage | 296-519 | Bidirectional backpressure for sink (consumer side) / controller (producer side) |
#deleteStreamController + destroy | 521-535 | Stream cleanup and end of lifecycle |
Message Envelope: the Three-Layer Wrapping of postMessage
postMessage payloads can only be JSON-serializable objects. MessageHandler defines its own protocol — every message crossing comObj carries these fields:
// Common envelope portion
{
sourceName: "main" | "worker" | "d1" | "d1_worker", // message source
targetName: "main" | "worker" | "d1" | "d1_worker", // message destination
// ... shape-specific fields
}On top of the envelope sit three message shapes:
| Shape | Key fields | Peer-side action | Corresponding API |
|---|---|---|---|
| Action (fire-and-forget) | action, data | Execute, no reply sent | send(action, data) |
| Action + Callback (request-reply) | action, data, callbackId | Execute, send back a callback with callbackId | sendWithPromise(action, data) |
| Callback (pure reply) | callback, callbackId, data/reason | This is the reply to a request; it is not routed to an actionHandler | — |
| Stream Start | action, data, streamId, desiredSize | Create sink, execute action (synchronously), reply with START_COMPLETE | sendWithStream(action, data) |
| Stream Control | stream, streamId | 8 kinds of stream control: PULL / CANCEL / CANCEL_COMPLETE, etc. | Automatically sent inside ReadableStream |
| Stream Data | stream, streamId, chunk | ENQUEUE / CLOSE / ERROR, sent by sink | Actively invoked by sink-side code |
#onMessage dispatches down three paths by field presence, mutually exclusive and with strict ordering:
if (data.stream) { /* streaming control or data */ }
if (data.callback) { /* reply carrying a callbackId */ }
// anything left is an actionsourceName / targetName form a soft route: #onMessage does a single exact match — data.targetName !== this.sourceName and the message is dropped. When multiple documents coexist (two parallel getDocument calls), docId is baked into the name (d1 / d1_worker / d2 / d2_worker), so each MessageHandler is naturally isolated and never crosses wires.
MessageHandler Class: Four Tables + One Listener
constructor(sourceName, targetName, comObj) {
this.sourceName = sourceName;
this.targetName = targetName;
this.comObj = comObj;
this.callbackId = 1;
this.streamId = 1;
this.streamSinks = Object.create(null); // streamId → sink (I am the data receiver here)
this.streamControllers = Object.create(null); // streamId → controller (I am the data producer here)
this.callbackCapabilities = Object.create(null); // callbackId → Promise.withResolvers()
this.actionHandler = Object.create(null); // actionName → handler
comObj.addEventListener("message", this.#onMessage.bind(this), {
signal: this.#messageAC.signal,
});
}The four tables' division of labor is straightforward:
actionHandler: remote actions I can respond to. Registered viaMessageHandler.on("GetPage", handler), triggered when the remote callssendWithPromise("GetPage", ...);callbackCapabilities: I send a request and wait for the remote to reply with a callbackId. Key is the callback id, value isPromise.withResolvers(). Ids are assigned in ascending order and deleted after use;streamControllers: I callsendWithStream, the remote is the data producer. On my side I hold the ReadableStream controller; the remote enqueues data into this sink;streamSinks: the remote callssendWithStream, I am the data producer. On my side I hold a sink object (withenqueue / close / errormethods); the remote ReadableStream's pull/cancel controls me.
Design note: The "my side / remote side" relationship across the four tables is relative. When the main thread calls
sendWithStream("GetOperatorList"), the main thread holdsstreamControllers[streamId]and the worker holdsstreamSinks[streamId]; when the worker callssendWithStream("GetReader")(pushing the full PDF data stream to the main thread), the relationship is exactly reversed. Each MessageHandler instance is simultaneously the producer for some streams and the consumer for others — which is why both Map tables are needed.
callbackId / streamId are both auto-increment integers, independently allocated by each MessageHandler. The worker and main thread each start from 1 and will never collide — because their MessageHandlers are two distinct instances.
The listener uses an AbortController signal — on destroy(), abort() removes the listener, and any messages that comObj.postMessage delivers afterwards will no longer invoke any callback.
on() / send() / sendWithPromise(): The First Two Shapes
on(): Register an action handler
on(actionName, handler) {
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
assert(typeof handler === "function",
'MessageHandler.on: Expected "handler" to be a function.');
}
const ah = this.actionHandler;
if (ah[actionName]) {
throw new Error(`There is already an actionName called "${actionName}"`);
}
ah[actionName] = handler;
}Only one handler may be registered per action name — a second call throws. This means when the remote says "I want to send a GetPage", the receiver has exactly one implementation, so you never end up debugging which handler actually responded.
In api.js, WorkerTransport.setupMessageHandler() calls .on(...) twenty-plus times in a row to register: GetDoc, DocException, PasswordRequest, commonobj, obj, StartRenderPage, DocProgress, FetchBinaryData, SaveDocument, Terminate, and more. All message types for a single doc are concentrated in this batch of .on calls — it is the core of how WorkerTransport translates "worker↔main-thread communication" into semantics.
send(): fire-and-forget
send(actionName, data, transfers) {
this.comObj.postMessage({
sourceName: this.sourceName,
targetName: this.targetName,
action: actionName,
data,
}, transfers);
}The simplest path. No callbackId, no streamId — the message is sent and done. api.js uses it in two places:
messageHandler.send("Ready", null)— the declaration closing out the handshake;messageHandler.send("configure", { verbosity })— PDFWorker passes the log level to the worker.
sendWithPromise(): request-reply
sendWithPromise(actionName, data, transfers) {
const callbackId = this.callbackId++;
const capability = Promise.withResolvers();
this.callbackCapabilities[callbackId] = capability;
try {
this.comObj.postMessage({
sourceName: this.sourceName,
targetName: this.targetName,
action: actionName,
callbackId,
data,
}, transfers);
} catch (ex) {
capability.reject(ex);
}
return capability.promise;
}The only difference from send: it attaches a callbackId and records the promise resolver in the table. When the remote processes the action and sees the callbackId field present, it sends back the result (or exception) as a callback-shaped message, and the callback branch of #onMessage invokes the resolver.
Two subtle points to note:
- The synchronous try-catch only guards
postMessage's own throws (re-transfer after transfer ownership was already released, non-serializable objects inside transfers, etc.). OncepostMessagereturns, any exception thrown while the remote processes the action is in the other side's event loop and must come back through the callback ERROR channel; - Callback messages must eventually be delivered — otherwise the caller's promise stays pending forever. This is why
.finallyblocks always.catch(() => {})to claim rejections: in a destroy race, MessageHandler may bedestroy()'d early and the callback is never received.
The four heavyweight calls in api.js (GetDocRequest / GetPage / GetMetadata / SaveDocument) all go through this channel.
sendWithStream(): The Third Shape, Most Complex
The streaming interface is wrapped in the web-standard ReadableStream. The caller receives a ReadableStream; the other end receives a sink object (with enqueue / close / error). Both sides coordinate backpressure through the 8 kinds of StreamKind messages inside #processStreamMessage.
Initiator: the ReadableStream's start / pull / cancel callbacks
sendWithStream(actionName, data, queueingStrategy, transfers) {
const streamId = this.streamId++,
sourceName = this.sourceName,
targetName = this.targetName,
comObj = this.comObj;
return new ReadableStream({
start: controller => {
const startCapability = Promise.withResolvers();
this.streamControllers[streamId] = {
controller, startCall: startCapability,
pullCall: null, cancelCall: null, isClosed: false,
};
comObj.postMessage({
sourceName, targetName,
action: actionName, streamId, data,
desiredSize: controller.desiredSize, // initial backpressure info
}, transfers);
return startCapability.promise; // tells ReadableStream: wait on this promise before pulling
},
pull: controller => {
const pullCapability = Promise.withResolvers();
this.streamControllers[streamId].pullCall = pullCapability;
comObj.postMessage({ sourceName, targetName,
stream: StreamKind.PULL, streamId,
desiredSize: controller.desiredSize });
return pullCapability.promise; // tells ReadableStream: wait for PULL_COMPLETE before pulling again
},
cancel: reason => {
assert(reason instanceof Error, "cancel must have a valid reason");
const cancelCapability = Promise.withResolvers();
this.streamControllers[streamId].cancelCall = cancelCapability;
this.streamControllers[streamId].isClosed = true;
comObj.postMessage({ sourceName, targetName,
stream: StreamKind.CANCEL, streamId,
reason: wrapReason(reason) });
return cancelCapability.promise; // tells ReadableStream: wait for CANCEL_COMPLETE
},
}, queueingStrategy);
}The start callback does not resolve directly — it first sends action + streamId + desiredSize to the peer, then returns startCapability.promise to the ReadableStream. Once the peer replies with START_COMPLETE (see #processStreamMessage below), startCall.resolve() is invoked, and only then does the ReadableStream begin pushing data to the consumer.
This is why in api.js, _pumpOperatorList can safely call reader.read() immediately after obtaining the readableStream — during start, the ReadableStream internally awaits, and does not accept pull requests until the remote sink is created and START_COMPLETE comes back.
pull fires whenever the ReadableStream's internal buffer has emptied enough that it needs more data. The next pull fires only after the returned promise resolves, so each pull round must wait for the peer's PULL_COMPLETE — this is the natural backpressure channel.
Receiver (peer side): #createStreamSink
The peer's MessageHandler discovers data.streamId present (not the stream field, but an action message carrying streamId) in #onMessage, and takes the #createStreamSink(data) path:
#createStreamSink(data) {
const streamSink = {
enqueue(chunk, size = 1, transfers) { /* see below */ },
close() { /* sends CLOSE */ },
error(reason) { /* sends ERROR */ },
sinkCapability: Promise.withResolvers(),
onPull: null, // triggered by PULL messages
onCancel: null, // triggered by CANCEL messages
isCancelled: false,
desiredSize: data.desiredSize, // initial value from start message
ready: null,
};
streamSink.sinkCapability.resolve();
streamSink.ready = streamSink.sinkCapability.promise;
this.streamSinks[streamId] = streamSink;
Promise.try(action, data.data, streamSink).then(
() => comObj.postMessage({ stream: StreamKind.START_COMPLETE, streamId, success: true }),
reason => comObj.postMessage({ stream: StreamKind.START_COMPLETE, streamId, reason: wrapReason(reason) })
);
}The streamSink is the active producer — the sink's owner (e.g. the worker's GetOperatorList handler) pushes data to the peer via enqueue(chunk); the passively responsive ones are onPull / onCancel — when the ReadableStream side needs more data (or is cancelled), MessageHandler invokes these two callbacks.
#processStreamMessage: 8-Kind Bidirectional State Machine
All stream-control and data messages flow through this single switch:
case StreamKind.PULL: // ReadableStream side needs more data → triggers sink.onPull
case StreamKind.PULL_COMPLETE: // sink.onPull finished → resolves the ReadableStream pull promise
case StreamKind.ENQUEUE: // sink sent data → controller.enqueue
case StreamKind.CLOSE: // sink ended → controller.close
case StreamKind.ERROR: // sink errored → controller.error
case StreamKind.CANCEL: // ReadableStream cancelled → triggers sink.onCancel
case StreamKind.CANCEL_COMPLETE: // sink.onCancel finished → resolves the ReadableStream cancel promise
case StreamKind.START_COMPLETE: // sink creation + action execution finished → resolves ReadableStream start promiseThink of it as two pairs of promise handshakes:
| ReadableStream-side action | Sends to peer | Peer replies with | This side resolves |
|---|---|---|---|
| start() | start message (with desiredSize) | START_COMPLETE | startCall |
| pull() | PULL | PULL_COMPLETE | pullCall |
| cancel() | CANCEL | CANCEL_COMPLETE | cancelCall |
| — | — | ENQUEUE / CLOSE / ERROR | controller operated directly, no promise |
enqueue backpressure: bidirectional flow of desiredSize
There is a piece of non-obvious logic inside sink.enqueue:
const lastDesiredSize = this.desiredSize;
this.desiredSize -= size;
if (lastDesiredSize > 0 && this.desiredSize <= 0) {
this.sinkCapability = Promise.withResolvers();
this.ready = this.sinkCapability.promise;
}desiredSize is the ReadableStream actively telling you "how much more I can take." Each time the sink enqueues a chunk, it subtracts the corresponding size; when the value drops to zero or below, the sink side suspends its own ready. This is a signal that "the producer can't outrun the consumer's pull" — the sink's owner (e.g. the worker's operatorList parsing loop) should await sink.ready before each enqueue; otherwise it blindly pushes until the peer's ReadableStream internal buffer bursts.
The matching trigger side lives inside the PULL message handler:
if (streamSink.desiredSize <= 0 && data.desiredSize > 0) {
streamSink.sinkCapability.resolve();
}
streamSink.desiredSize = data.desiredSize;When a peer pull arrives, it resets desiredSize to the new value (the ReadableStream should have consumed some data by then) and resolves the sink's suspended ready when the value transitions from non-positive to positive. desiredSize flows bidirectionally — decremented by sink.enqueue, reset on PULL arrival — forming the complete backpressure ring.
#deleteStreamController: wait for all three promises to settle before deleting
async #deleteStreamController(streamController, streamId) {
await Promise.allSettled([
streamController.startCall?.promise,
streamController.pullCall?.promise,
streamController.cancelCall?.promise,
]);
delete this.streamControllers[streamId];
}When the controller is triggered for deletion by any of CLOSE / ERROR / CANCEL_COMPLETE, it cannot be deleted immediately — the ReadableStream internally may still be waiting on pullCall.promise (it is queued for execution, not yet resolved). An immediate delete would make pullCall.resolve(...) throw TypeError: Cannot read properties of undefined. Promise.allSettled guarantees all three promises settle before cleanup, letting every pending callback inside the ReadableStream conclude safely.
#onMessage: The Three Shapes of the Dispatch Main Loop
#onMessage({ data }) {
if (data.targetName !== this.sourceName) return; // soft route
if (data.stream) {
this.#processStreamMessage(data); // StreamKind control or data
return;
}
if (data.callback) {
// pure reply: fetch capability from callbackCapabilities, resolve or reject by DATA/ERROR
return;
}
// anything left is an action
const action = this.actionHandler[data.action];
if (!action) throw new Error(`Unknown action from worker: ${data.action}`);
if (data.callbackId) {
// action + callbackId: execute, send result/exception back as callback
Promise.try(action, data.data).then(
result => comObj.postMessage({ callback: DATA, callbackId, data: result }),
reason => comObj.postMessage({ callback: ERROR, callbackId, reason: wrapReason(reason) })
);
return;
}
if (data.streamId) {
// action + streamId: create sink, do not reply in action shape
this.#createStreamSink(data);
return;
}
action(data.data); // pure action, fire-and-forget
}A few points worth expanding:
What Promise.try does: it wraps actions that might throw synchronously inside a microtask, so that subsequent .then/.catch can catch them. Without it, action(data.data) throwing synchronously would blow up in the current event loop instead of going through the ERROR callback path.
There are two callbackId replies: DATA and ERROR. Both sides use wrapReason to preserve Error types.
data.callbackId present but data.streamId absent → request-reply; data.streamId present → streaming; data.callback present → pure reply. The three are mutually exclusive. #onMessage distinguishes them purely by field presence, not by trial-and-error try/catch — this is an intentional design choice to avoid message-semantics ambiguity.
wrapReason: Preserving Error Identity Across Threads
postMessage does not pass Error instances as Errors — it serializes them into plain objects { name, message, ... }. On the receiving end, the name field must be used to reconstruct PDF.js's custom exception types:
function wrapReason(ex) {
// already the correct type → return directly (avoid a second construction)
if (ex instanceof AbortException || ex instanceof InvalidPDFException ||
ex instanceof PasswordException || ex instanceof ResponseException ||
ex instanceof UnknownErrorException) {
return ex;
}
if (!(ex instanceof Error || (typeof ex === "object" && ex !== null))) {
unreachable('wrapReason: Expected "reason" to be a (possibly cloned) Error.');
}
switch (ex.name) {
case "AbortException": return new AbortException(ex.message);
case "InvalidPDFException": return new InvalidPDFException(ex.message);
case "PasswordException": return new PasswordException(ex.message, ex.code);
case "ResponseException": return new ResponseException(ex.message, ex.status, ex.missing);
case "UnknownErrorException": return new UnknownErrorException(ex.message, ex.details);
}
return new UnknownErrorException(ex.message, ex.toString());
}Five custom exception types, each with its own extra fields (PasswordException.code / ResponseException.status + missing / UnknownErrorException.details), all preserved verbatim by wrapReason. The worker side calls wrapReason once (on send), and the main-thread side calls wrapReason again (on receive) — but since it is already the correct instanceof type, the second wrap short-circuits and returns the original without re-constructing.
destroy(): End of Lifecycle
destroy() {
this.#messageAC?.abort();
this.#messageAC = null;
}AbortController.abort() removes the message listener on comObj, so no matter how many messages comObj delivers afterwards, #onMessage will never fire again. The four tables (callbackCapabilities / streamControllers / streamSinks / actionHandler) are not explicitly cleared, but if the capability objects they hold are referenced externally, they enter a permanently pending state that is never resolved or rejected — which is exactly why api.js calls .catch(() => {}) to claim _capability.promise before destroy().
LoopbackPort: Isomorphic Communication Under the Fake-Worker Fallback
pdf.WorkerTransport uses LoopbackPort in _setupFakeWorker to stand in for a real MessagePort:
const port = new LoopbackPort();
const id = `fake${PDFWorker.#fakeWorkerId++}`;
const workerHandler = new MessageHandler(id + "_worker", id, port);
WorkerMessageHandler.setup(workerHandler, port);
this.#messageHandler = new MessageHandler(id, id + "_worker", port);The two MessageHandler instances share the same LoopbackPort — whichever side calls postMessage, the other side's listener is invoked immediately (synchronous dispatch). For MessageHandler, the LoopbackPort.addEventListener("message", cb) + postMessage(obj) interface is identical to a real MessagePort — so the MessageHandler layer is completely agnostic to whether it is dealing with same-thread or cross-thread communication.
This is precisely the "capability fallback that does not disturb upper layers" pattern mentioned in the api.js article: LoopbackPort only turns communication from "asynchronous postMessage" into "synchronous event firing"; all three MessageHandler APIs (send / sendWithPromise / sendWithStream) and all three tables' usage remain exactly the same.
Summary
MessageHandler is the minimal-primitive wrapper around main-thread↔worker communication in PDF.js — it carries no business semantics like "read a file" or "fetch a page"; it only establishes three message shapes and an envelope protocol.
Landing scenarios for the three communication shapes
| Shape | Caller | Business meaning |
|---|---|---|
send | WorkerTransport.on("Ready", ...) | Declaration: "my side is ready" |
sendWithPromise | WorkerTransport._setupCapability | "Please give me a receipt for GetDocRequest" |
sendWithStream | PDFPageProxy._pumpOperatorList | "Push the operatorList over in chunks" |
| streamSink.enqueue | worker-side GetOperatorList handler | "I just finished parsing one chunk of draw commands" |
Design principles of the envelope protocol
- Soft route does not drop messages:
sourceName/targetNamemismatch onlyreturns, never throws. In multi-document coexistence scenarios, a large number of messages are naturally filtered out. - Requests must always reply with a callback: actions carrying
callbackIdmust reply with a callback after execution, or the caller hangs. This is a contract, not a suggestion. - Flow control is naturally built in: the ReadableStream's
desiredSizeflows across both sides via two postMessage calls (at start time and on every pull round). The sink side suspendsreadyaccordingly, forming the complete backpressure ring. - Error identity is preserved:
wrapReasonensures PDF.js custom exceptions crossing thread boundaries remain the correctinstanceoftype on the other side. - Isomorphic interface equals fallback capability: LoopbackPort and MessagePort have identical interfaces, making the fake-worker fallback transparent to MessageHandler.
Once you understand MessageHandler, the four heavyweight calls from the api.js articles (GetDocRequest / Ready / GetOperatorList / GetTextContent) cease to be "a vague sentence about worker communication" — you will know precisely which table callbackId goes into, which two tables streamId goes into, and how desiredSize flows back and forth.
The next installment should cover network streams (fetch_stream.js + network_stream.js) — GetReader / GetRangeReader messages run on MessageHandler's stream channel; MessageHandler is the foundation beneath them.
StreamPDF design principle: anything that can be handled ahead of time is absolutely not deferred to parse time; the server solves all performance problems once. The browser only fetches and renders the data it must.
Related articles
- PDF.js Deep Dive from Demo 10: CanvasGraphics and operatorList RenderingOctober 27, 2026
- PDF.js Deep Dive from Demo 9: Network Streams and Range RequestsOctober 27, 2026
- PDF.js Deep Dive from Demo 8: PDFWorker.create and the Real/Fake Worker HandshakeOctober 27, 2026
- PDF.js Deep Dive from Demo 7: WorkerTransport InitializationOctober 27, 2026
- PDF.js Deep Dive from Demo 6: MessageHandler Cross-Thread CommunicationOctober 27, 2026
- PDF.js Deep Dive from Demo 5: renderOctober 27, 2026
- PDF.js Deep Dive from Demo 4: getViewportOctober 27, 2026
- PDF.js Deep Dive from Demo 3: getPageOctober 27, 2026
- PDF.js Deep Dive from Demo 2: getDocumentOctober 27, 2026
- PDF.js Source Walkthrough: TextLayerOctober 27, 2026
- PDF.js Source Walkthrough: font_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_displayOctober 27, 2026
- PDF.js Source Walkthrough: PDFObjectsOctober 27, 2026
- PDF.js Source Walkthrough: Network StreamsOctober 27, 2026
- PDF.js Source Walkthrough: internal_evt.jsOctober 27, 2026
- PDF.js Source Walkthrough: math_clamp.jsOctober 27, 2026
- PDF.js Source Walkthrough: css_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: scripting_utilsOctober 27, 2026
- PDF.js Source Walkthrough: MurmurHash3_64October 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_utilsOctober 27, 2026
- PDF.js Source Walkthrough: image_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: BasePDFStreamOctober 27, 2026
- PDF.js Source Walkthrough: Shared UtilitiesOctober 27, 2026
- PDF.js Source Walkthrough: apiOctober 27, 2026
- PDF.js Source Walkthrough: fontsOctober 27, 2026
- PDF.js Source Walkthrough: glyphlistOctober 27, 2026
- PDF.js Source Walkthrough: binary_cmapOctober 27, 2026
- PDF.js Source Walkthrough: cmapOctober 27, 2026
- PDF.js Source Walkthrough: encodingsOctober 27, 2026
- PDF.js Source Walkthrough: metricsOctober 27, 2026
- PDF.js Source Walkthrough: standard_fontsOctober 27, 2026
- PDF.js Source Walkthrough: fonts_utilsOctober 27, 2026
- PDF.js Source Walkthrough: default_appearanceOctober 27, 2026
- PDF.js Source Walkthrough: metadata_parserOctober 27, 2026
- PDF.js Source Walkthrough: name_number_treeOctober 27, 2026
- PDF.js Source Walkthrough: struct_treeOctober 27, 2026
- PDF.js Source Walkthrough: catalogOctober 27, 2026
- PDF.js Source Walkthrough: pdf_managerOctober 27, 2026
- PDF.js Source Walkthrough: object_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_coreOctober 27, 2026
- PDF.js Source Walkthrough: cryptoOctober 27, 2026
- PDF.js Source Walkthrough: core_utilsOctober 27, 2026
- PDF.js Source Walkthrough: primitivesOctober 27, 2026
- PDF.js Source Walkthrough: worker_streamOctober 27, 2026
- PDF.js Source Walkthrough: ccitt_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpx_streamOctober 27, 2026
- PDF.js Source Walkthrough: jbig2_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpeg_streamOctober 27, 2026
- PDF.js Source Walkthrough: decrypt_streamOctober 27, 2026
- PDF.js Source Walkthrough: brotli_streamOctober 27, 2026
- PDF.js Source Walkthrough: run_length_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_hex_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_85_streamOctober 27, 2026
- PDF.js Source Walkthrough: lzw_streamOctober 27, 2026
- PDF.js Source Walkthrough: predictor_streamOctober 27, 2026
- PDF.js Source Walkthrough: flate_streamOctober 27, 2026
- PDF.js Source Walkthrough: decode_streamOctober 27, 2026
- PDF.js Source Walkthrough: chunked_streamOctober 27, 2026
- PDF.js Source Walkthrough: streamOctober 27, 2026
- PDF.js Source Walkthrough: base_streamOctober 27, 2026
- PDF.js Deep Dive 11: ParserOctober 26, 2026
- PDF.js Deep Dive 10: XRefOctober 26, 2026
- PDF.js Deep Dive 9: CanvasGraphicsOctober 26, 2026
- PDF.js Deep Dive 8: PDFDocumentOctober 26, 2026
- The AI Era: What Should We Do to Survive? (Reflections on Founding StreamPDF)October 26, 2026
- PDF.js Deep Dive 7: PartialEvaluatorOctober 25, 2026
- PDF.js Deep Dive 6: PDFManager.initDocumentOctober 25, 2026
- PDF.js Deep Dive 5: PDFFetchStream and PDFWorkerStreamOctober 25, 2026
- PDF.js Deep Dive 4: MessageHandlerOctober 22, 2026
- PDF.js Deep Dive 3: getViewport & renderOctober 22, 2026
- PDF.js Deep Dive from Demo 1: Rendering a PDF with a Custom ViewerOctober 19, 2026
- PDF.js Deep Dive 2: getDocument & getPageOctober 19, 2026
- PDF.js Deep Dive 1: The Architecture of PDF.jsOctober 16, 2026