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

Introduction
This is the second article in the "Bytes and Streams" layer.
The previous article, chunked_stream, solved "where the bytes come from": how network slices become an addressable buffer on demand. This article covers the next leg — how those bytes get decoded.
PDF stream objects almost never hold raw data directly. A page content stream is usually Flate-compressed, a font file may be wrapped in ASCII85 and LZW, an image is one of DCT, JPX, JBIG2 or CCITT, and some streams go through an additional PNG or TIFF predictor. The parser never reads the bytes stored in the file directly; it reads the bytes left after every filter has been undone.
PDF.js organises all its decoders into one inheritance chain: DecodeStream is the common base class, and FlateStream, LZWStream, Ascii85Stream, RunLengthStream, PredictorStream, JpegStream and the rest extend it. The base class knows nothing about any specific algorithm; it provides exactly three things:
- An output buffer that grows automatically.
- A read interface driven by
readBlockas its only source. - Two channels that materialise decoded output into an ordinary
Stream:makeSubStreamandclone.
A subclass only has to do one thing: implement readBlock, write the decoded result into the buffer (text decoders append piece by piece, image decoders often replace it wholesale), and set eof to true when the source data is exhausted.
In one sentence: DecodeStream is the trio of "output buffer, read cursor, abstract readBlock", and every filter is mounted on that skeleton.
Module Overview
decode_stream.js is 246 lines long and splits into six parts:
| Block | Key methods | Responsibility |
|---|---|---|
| State and construction | buffer, bufferLength, minBufferLength, constructor | Output buffer, logical length, minimum capacity |
| Buffer management | ensureBuffer | Grow on demand, return a writable buffer |
| Reading | getByte, getBytes, isEmpty | Drive readBlock on demand, return zero-copy views |
| Images and decompression | getImageData, asyncGetBytesFromDecompressionStream | Fetch a whole block asynchronously, native DecompressionStream |
| Derivation | makeSubStream, clone, getBaseStreams | Hand decoded output to an ordinary Stream |
| Multi-stream stitching | StreamsSequenceStream | Join several streams end to end into one |
Where it sits in the chain:
| Layer | Module | Input | Output |
|---|---|---|---|
| Byte source | Stream / ChunkedStream | File or network slices | Readable stream |
| Decoding | DecodeStream and subclasses | Encoded bytes | Decoded output buffer |
| Composition | StreamsSequenceStream | Multiple streams | A single stream |
| Parsing | Lexer / Parser | Decoded bytes | tokens, Dict, objects |
| Image objects | JpegStream / JpxStream and others | A whole block of image data | ImageBitmap or bitmap data |
The chain in one sentence: the byte source supplies encoded data, DecodeStream subclasses decode it chunk by chunk into the output buffer, and the parser only ever sees contiguous decoded bytes.
The Base-Class Contract: Implement Only readBlock
const emptyBuffer = new Uint8Array(0);
// Super class for the decoding streams.
class DecodeStream extends BaseStream {
buffer = emptyBuffer;
bufferLength = 0;
eof = false;
minBufferLength = 512;
pos = 0;
constructor(maybeMinBufferLength) {
super();
this._rawMinBufferLength = maybeMinBufferLength || 0;
if (maybeMinBufferLength) {
// Compute the first power of two that is as big as maybeMinBufferLength.
while (this.minBufferLength < maybeMinBufferLength) {
this.minBufferLength *= 2;
}
}
}
readBlock() {
unreachable("Abstract method `readBlock` called");
}emptyBuffer is a shared zero-length array. The comment explains why it is shared instead of using null: many DecodeStream instances never produce any data during their lifetime, so one shared empty array saves memory and avoids writing a "buffer is empty" special case into every read method.
Each field has a clear role:
| Field | Meaning |
|---|---|
buffer | Output buffer, initially the shared emptyBuffer |
bufferLength | Logical length, the number of valid bytes produced so far |
buffer.byteLength | Capacity, how much memory is currently allocated |
minBufferLength | Growth starting point, 512 by default, raised to a power of two no smaller than the constructor argument |
_rawMinBufferLength | The raw capacity hint passed at construction, used by StreamsSequenceStream to estimate |
pos | Read cursor, independent of bufferLength |
eof | Whether the source data has been exhausted |
The constructor argument maybeMinBufferLength is a hint, not an exact length. Many decoding streams can obtain the compressed length from a dictionary, and the decompressed size is usually larger, so it is used as a starting point to avoid doubling up from 512 over and over. minBufferLength is raised to "the smallest power of two that is not less than the hint", so the later growth sequence is the tidy 512, 1024, 2048 and so on.
Design note: The base class defines the interface only, never the algorithm. The sole contract of
readBlockis "produce a batch of bytes and updatebufferLength, or seteofto true". The base class does not check how many bytes were produced, which filter produced them, or whether decoding finished in one go. That extremely narrow contract lets more than a dozen completely different decoders share the same reading, growing and derivation logic.
Buffer Growth: ensureBuffer
ensureBuffer(requested) {
const buffer = this.buffer;
if (requested <= buffer.byteLength) {
return buffer;
}
let size = this.minBufferLength;
while (size < requested) {
size *= 2;
}
const buffer2 = new Uint8Array(size);
buffer2.set(buffer);
return (this.buffer = buffer2);
}ensureBuffer is the only memory function a subclass needs to care about. It decides based on capacity, not logical length: as long as the currently allocated memory can hold the request, it reuses the buffer, no matter how many valid bytes it contains.
The growth policy is "start at minBufferLength and keep doubling until the request fits". That gives three properties:
- The number of allocations is O(log n); going from 512 bytes to tens of megabytes takes only a dozen or so allocations.
- Total copying is O(n): old contents are copied each time, but capacity doubles, so the amortised copy cost per byte is constant.
- Peak memory usually stays below twice the requested amount; when the request is smaller than 512, the lower bound
minBufferLengthapplies.
| Policy | Allocations | Total copying | Peak memory | Suitable for |
|---|---|---|---|---|
| Exact allocation every time | O(n) | O(n²) | Exactly what is needed | Cases where the final size is known up front |
| Geometric growth (this code) | O(log n) | O(n) | At most twice | Decoders of unknown size that produce chunk by chunk |
Copying uses buffer2.set(buffer), which copies the old buffer's entire capacity, including the bytes after bufferLength that have never been used. Those bytes are garbage; copying them costs a little time and cannot affect correctness, and in exchange the implementation never has to distinguish "valid region" from "free region".
Design note: Separating capacity from logical length is what allows a decoding stream to append repeatedly.
bufferLengthgrows as decoding progresses, whilebuffer.byteLengthonly jumps when the buffer cannot hold more. The read interface depends only on the former, the growth logic only on the latter, and the two never interfere.
Reading: Driving readBlock on Demand
getByte() {
const pos = this.pos;
while (this.bufferLength <= pos) {
if (this.eof) {
return -1;
}
this.readBlock();
}
return this.buffer[this.pos++];
}getByte is the core of the whole mechanism. It uses a while rather than an if, because a single readBlock may produce no bytes at all:
- A Flate block may only update internal state.
- A predictor may have read a row but not yet accumulated enough output.
- An image decoder may decode the whole image at once, or may be waiting for asynchronous data.
The loop condition bufferLength <= pos means "there is no valid byte at the current cursor". As long as eof is not reached, the subclass keeps producing; once eof is true and there are still no bytes, -1 is returned, matching the end-of-stream semantics of Stream.
This also implies an obligation on every subclass: readBlock must either produce bytes or set eof. Otherwise the loop never terminates. The base class has no timeout or iteration guard, because it trusts every subclass to honour the contract.
getBytes(length, decoderOptions = null) {
const pos = this.pos;
let end;
if (length) {
this.ensureBuffer(pos + length);
end = pos + length;
while (!this.eof && this.bufferLength < end) {
this.readBlock(decoderOptions);
}
const bufEnd = this.bufferLength;
if (end > bufEnd) {
end = bufEnd;
}
} else {
while (!this.eof) {
this.readBlock(decoderOptions);
}
end = this.bufferLength;
}
this.pos = end;
return this.buffer.subarray(pos, end);
}getBytes has two modes:
- With a length: grow the capacity to
pos + lengthfirst, then decode in a loop untilendis covered oreofis reached. If the source data ends early,endis clamped tobufferLength, so the caller gets a shorter span instead of an error. - Without a length: decode all the way to
eofand return every byte from the current position to the end.
Calling ensureBuffer(pos + length) up front folds this request's size into the capacity once, avoiding repeated checks and many small growth steps while decoding advances byte by byte. subarray is taken only after reading finishes, and the view points into the final buffer; even if some readBlock produced more bytes than requested and triggered another growth, the view is created after all appends, so it never points at the old array.
decoderOptions is passed through to readBlock unchanged. Ordinary text decoders ignore it; image decoders use it to control scaling, colour transforms and similar parameters. The base class does not interpret it, only forwards it.
| Call site | When readBlock is triggered | Returns |
|---|---|---|
getByte | When there is no valid byte at the cursor | One byte, or -1 |
getBytes(n) | Until n bytes are covered or eof is reached | A view of [pos, pos + n) |
getBytes() | Until eof | A view of all remaining bytes |
isEmpty | Until there is output or eof | A boolean |
makeSubStream | Until the substream end is covered | A new Stream |
clone | Until eof | A new Stream |
get isEmpty() {
while (!this.eof && this.bufferLength === 0) {
this.readBlock();
}
return this.bufferLength === 0;
}isEmpty does not simply compare lengths; it is a lazy check: as long as there is no output and the end has not been reached, it keeps decoding, until the first byte is produced or the stream truly ends. So when a caller asks "does this stream have any data", the answer reflects the decoded reality rather than the intermediate state "the buffer happens to be empty right now".
The price is that isEmpty may trigger a large amount of decoding work. It is therefore only appropriate when an empty-stream check is genuinely needed, and should not be called repeatedly on a hot path.
Images and Asynchronous Decoding
getImageData
Image streams differ from text streams: the parser wants the whole decoded pixel block, not bytes read while parsing. getImageData provides three paths for that:
async getImageData(length, decoderOptions) {
if (!this.canAsyncDecodeImageFromBuffer) {
return this.isAsyncDecoder
? this.decodeImage(null, length, decoderOptions)
: this.getBytes(length, decoderOptions);
}
const data = await this.stream.asyncGetBytes();
return this.decodeImage(data, length, decoderOptions);
}canAsyncDecodeImageFromBuffer | isAsyncDecoder | Path | Explanation |
|---|---|---|---|
| No | No | getBytes(length, options) | Ordinary synchronous decoding, returns bytes directly |
| No | Yes | decodeImage(null, length, options) | The decoder reads data from the upstream stream itself |
| Yes | Either | stream.asyncGetBytes() first, then decodeImage(data, ...) | Fetch the whole encoded block first, then hand it to the decoder |
canAsyncDecodeImageFromBuffer is implemented in JpegStream, JpxStream and Jbig2Stream as "is the upstream stream asynchronous". When it is true, the whole encoded block is fetched first and decoded afterwards, which avoids repeatedly interrupting the decoder while it waits for the network. A true isAsyncDecoder means the decoder itself is asynchronous (JpxStream, Jbig2Stream and CCITTFaxStream are), so null is passed and decodeImage fetches the whole block from upstream through this.bytes itself.
decodeImage is implemented by each image stream subclass and returns decoded pixel data. The base class does not implement it; it only chooses the path.
asyncGetBytesFromDecompressionStream
async asyncGetBytesFromDecompressionStream(name) {
this.stream.reset();
const bytes = this.stream.isAsync
? await this.stream.asyncGetBytes()
: this.stream.getBytes();
try {
const { readable, writable } = new DecompressionStream(name);
const writer = writable.getWriter();
await writer.ready;
// We can't await writer.write() because it'll block until the reader
// starts which happens few lines below.
writer
.write(bytes)
.then(async () => {
await writer.ready;
await writer.close();
})
.catch(() => {});
const chunks = [];
let totalLength = 0;
for await (const chunk of readable) {
chunks.push(chunk);
totalLength += chunk.byteLength;
}
const data = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
data.set(chunk, offset);
offset += chunk.byteLength;
}
return { decompressed: data, compressed: bytes };
} catch {
return { decompressed: null, compressed: bytes };
}
}This is a fast path that trades browser-native decompression for speed, in several steps:
- Reset the upstream stream.
this.stream.reset()sends the read position back to the start; if the upstream is asynchronous, it also waits for the whole encoded block to arrive. - Fetch the compressed bytes in one go. When
isAsyncis true it usesasyncGetBytes, otherwise the synchronousgetBytes. Both return the complete data, so the upstream is never read again afterwards. - Construct the
DecompressionStream.nameis a format string such as"deflate"or"brotli", and the browser's decompression implementation picks the algorithm. - Wait for
writer.ready, then write. Writing applies backpressure: when nobody is consuming the readable side,writedoes not complete. So the code deliberately does notawaitthe write; it starts writing and immediately enters the read loop. The write chain completes naturally once the consumer starts, and closes the writer when done. - Collect and concatenate the output.
for awaityields decompressed slices one by one, the total length is accumulated, and a singleUint8Arrayis allocated and filled. Compared with pushing and growing repeatedly, this concatenation allocates only once. - Keep the compressed bytes on failure. The whole flow sits inside
try; ifDecompressionStreamdoes not exist, the format is unsupported, or a stray byte at the end of the data breaks decompression, the method returnsdecompressed: nulland hands back thecompressedbytes it already has.
Step 6 is what makes this fast path safe to use. After receiving the empty result, the caller can fall back to its own JavaScript decoder without reading upstream again — the compressed bytes are already in hand.
FlateStream and BrotliStream do exactly that:
- On success, return the decompressed result directly.
- On failure, set
#isAsyncto false, build an ordinaryStreamfromcompressed(Flate skips the two-byte header),reset()it and run their own incremental decoder;asyncGetBytesreturnsnull, while their overriddengetImageDataswitches togetBytes(length)when it gets an empty result.
Design note: A native fast path must be able to fail, able to fall back, and able to reuse its input.
asyncGetBytesFromDecompressionStreambuilds all three into its return value: failure is expressed asnull, the compressed bytes needed for fallback come back with the result, and reading the upstream from the start guarantees complete input. If any of these were missing, fallback would turn into a second read or into polluted state.
Substreams and Clones: Materialising Decoded Output
makeSubStream
makeSubStream(start, length, dict = null) {
if (length === undefined) {
while (!this.eof) {
this.readBlock();
}
} else {
const end = start + length;
while (this.bufferLength <= end && !this.eof) {
this.readBlock();
}
}
return new Stream(this.buffer, start, length, dict);
}There is no laziness in DecodeStream's makeSubStream: decoding must advance to the substream's end before an ordinary Stream can be constructed.
- When
lengthisundefined, decode all the way toeof, then wrap the span fromstartto the end of the buffer as a stream. - When
lengthis given, decode until the buffer coversend, then construct[start, start + length).
The returned Stream shares the same underlying Uint8Array as the decoding stream and copies no data. It is effectively a snapshot of the decoded output: even if the decoding stream later grows again or swaps its array, the substream still holds the old buffer and what it sees never changes.
This is the exact opposite of the "live view" provided by ChunkedStream.makeSubStream:
| Dimension | DecodeStream.makeSubStream | ChunkedStream.makeSubStream |
|---|---|---|
| Data preparation | Decode to the target end first | ensureRange first; missing chunks throw |
| Returned object | An ordinary Stream | A substream whose prototype is the parent instance |
| Underlying memory | Shares the decoded buffer | Shares the parent stream buffer |
| Later changes | A snapshot that no longer changes | A live view; chunks arriving at the parent become visible automatically |
| Missing data | Driven by readBlock, may block | Throws MissingDataException; the layer above fetches more |
clone
clone() {
// Make sure it has been fully read.
while (!this.eof) {
this.readBlock();
}
return new Stream(this.buffer, 0, this.bufferLength, this.dict?.clone());
}clone is even more demanding: the entire stream must be read. The new Stream it returns starts at 0, has a length equal to bufferLength, shares the buffer with the decoding stream, but gets its own read cursor. The dictionary is also shallow-cloned, so the two streams do not share one mutable dictionary.
Why read the whole stream? Because Stream needs a definite end, and the decoding stream's eof only becomes true once decoding truly finishes. Cloning after reading only part of it would give the new stream a temporary length that subsequent decoding would never reflect.
clone and makeSubStream cost the same: calling them completes the remaining decoding immediately. They suit the case where "the parser needs to store a stream and read it again from the start later", and are a poor fit for frequent calls that only read a small span.
StreamsSequenceStream: Joining Multiple Streams
Construction
class StreamsSequenceStream extends DecodeStream {
constructor(streams, onError = null) {
streams = streams.filter(s => s instanceof BaseStream && !s.isImageStream);
let maybeLength = 0;
for (const stream of streams) {
maybeLength +=
stream instanceof DecodeStream
? stream._rawMinBufferLength
: stream.length;
}
super(maybeLength);
this.streams = streams;
this._onError = onError;
}Construction filters first, then estimates capacity:
- Only
BaseStreaminstances are kept, and image streams are excluded along the way. Image stream data is usually handled separately as a transferable object, so mixing it into text stitching makes no sense. - Capacity estimation adds a
DecodeStream's_rawMinBufferLengthto other streams'length. The former is the raw hint from construction, the latter a known length; both are estimates, and actual growth is still the job ofensureBuffer.
The estimate reaches minBufferLength through super(maybeLength), so the stitching stream starts with a capacity close to its final size and needs fewer intermediate growth steps.
readBlock
readBlock() {
const streams = this.streams;
if (streams.length === 0) {
this.eof = true;
return;
}
const stream = streams.shift();
let chunk;
try {
chunk = stream.getBytes();
} catch (reason) {
if (this._onError) {
this._onError(reason, stream.dict?.objId);
return;
}
throw reason;
}
const bufferLength = this.bufferLength;
const newLength = bufferLength + chunk.length;
const buffer = this.ensureBuffer(newLength);
buffer.set(chunk, bufferLength);
this.bufferLength = newLength;
}Each readBlock handles exactly one substream:
- If the list is empty, set
eofand stop. shifta stream off the front and take all of its remaining bytes.getBytes()without a length means the whole substream is read in one go.- On error, if there is an
onErrorcallback, hand over the error and the object number and return immediately; that stream is dropped, and the nextreadBlockmoves on to the following one. - On success, append
chunkto the end of the output buffer and updatebufferLength.
shift ensures each substream is consumed exactly once, which also means stitching is "read and discard": a stream that has been merged into the output is referenced no more. getBytes() reads each substream in full, so the stitching stream's memory grows with the output rather than retaining every substream.
The error behaviour deserves its own table:
| Situation | Behaviour |
|---|---|
| The substream list is empty | Set eof = true and stop |
A substream throws, and onError exists | Call onError(reason, objId), skip that stream, leave eof alone |
A substream throws, and there is no onError | Rethrow unchanged; the layer above decides what to do |
| The last substream is consumed | Append normally this time; eof is set only when the next readBlock finds the list empty |
The presence of onError means one bad stream inside a page's content streams does not bring down the whole page. When PDFDocument assembles page content, it passes in an error handler, reports the object number of the failing substream, and carries on stitching the rest.
getBaseStreams() {
const baseStreamsBuf = [];
for (const stream of this.streams) {
const baseStreams = stream.getBaseStreams();
if (baseStreams) {
baseStreamsBuf.push(...baseStreams);
}
}
return baseStreamsBuf.length > 0 ? baseStreamsBuf : null;
}getBaseStreams iterates over substreams that have not yet been consumed. Streams already shifted out no longer participate, because their bytes are already part of the output and their original source no longer means anything to the layer above. When there is no traceable source it returns null, matching the base class's default semantics.
readBlock Strategies of the Subclasses
StreamsSequenceStream is only one of many implementations. Putting the common subclasses side by side shows how elastic the readBlock contract is:
| Subclass | readBlock strategy | Output granularity | Notes |
|---|---|---|---|
FlateStream | Incremental inflate, decoding by block header and Huffman tables | Several bytes | Prefers native DecompressionStream, falls back on failure |
LZWStream | Incremental dictionary decoding, code width growing from 9 to 12 bits | 512 bytes per block | Must maintain the dictionary and prefix sequences |
RunLengthStream | Read a two-byte header, copy or repeat a run by count | One run | A count of 128 means end of data |
Ascii85Stream | Four bytes out of every five characters, z means four zeros | One group | Whitespace is skipped |
AsciiHexStream | One byte out of two hexadecimal characters | One batch | Ends at the greater-than sign; other non-hex characters are skipped |
PredictorStream | Wraps the upstream stream and reverses TIFF or PNG prediction per row | One row | Replaces readBlock with the matching variant at construction |
DecryptStream | Decrypts 512 bytes at a time, reading one block ahead to detect the last | 512 bytes | Needs to know the final block to pad correctly |
JpegStream, JpxStream and others | Decode the whole image at once and set buffer to the result | One whole image | Mostly asynchronous, used with getImageData |
StreamsSequenceStream | Swallow the next substream and append all its bytes to the output | One substream | The subject of this section |
| Dimension | Chunk-by-chunk text decoders (Flate, LZW, etc.) | Whole-image decoders (JPEG, JPX, etc.) |
|---|---|---|
Number of readBlock calls | Many, until the source is consumed or eof | Usually one |
How buffer is written | Appends after repeated ensureBuffer calls | Replaces the whole buffer |
When eof is set | At the end of the source data | When decoding completes |
| Fit with the read interface | Naturally suited to byte-by-byte reading | Mostly goes through getImageData |
Design note: The stitching stream folds "many streams" into "one stream" by treating substreams as data blocks instead of maintaining many cursors. Each substream is discarded as it is consumed, and the output buffer keeps only the final result. The parser above never needs to know that the original page content was split into several pieces.
Conclusion
decode_stream.js is PDF.js's decoding foundation: upward it turns the various filters into contiguous bytes, and downward it reuses the reading and derivation semantics of Stream.
Where It Sits in the Overall Chain
| Stage | Module | Input | Output |
|---|---|---|---|
| Byte supply | Stream / ChunkedStream | File, network slices | Encoded bytes |
| Decoding | DecodeStream subclasses | Encoded bytes | Output buffer |
| Composition | StreamsSequenceStream | Multiple decoded streams | A single stream |
| Lexing | Lexer | Decoded bytes | tokens |
| Parsing | Parser | tokens | Dict, arrays, BaseStream |
In one sentence: the byte source supplies encoded data, DecodeStream decodes it into a buffer through the uniform readBlock contract, and the parser turns that buffer into objects.
Five Design Principles
- One abstraction point; subclasses write only
readBlock. The base class knows no algorithm and handles only buffering, reading and derivation, so more than a dozen filters can share one set of read semantics. - Capacity and length are separate, with geometric growth.
buffer.byteLengthmanages memory,bufferLengthmanages valid data; growth doubles fromminBufferLength, and the amortised copy cost is constant. - Reads return views with zero copying.
getBytesreturns asubarraydirectly, andmakeSubStreamandcloneshare the underlying buffer; when an independent cursor is needed, a newStreamwrapper is created without copying bytes. - Asynchronous decoding comes first, with a fallback on failure.
getImageDataprefers the asynchronous path, and whenDecompressionStreamfails, the compressed bytes are handed back to the caller so a JavaScript decoder can take over without a second read. - Materialise before deriving.
makeSubStreamandcloneboth read all the data they need before handing it to an ordinaryStream; the stitching stream swallows one substream at a time. Laziness stays at the "decode chunk by chunk" level and never spreads into derived objects.
This wraps up the two articles on "Bytes and Streams": chunked_stream turns network bytes into an addressable buffer, and decode_stream turns encoded bytes into a decoded buffer. The next article returns to parser.js to see how Lexer cuts tokens out of that buffer, and how Parser assembles those tokens into PDF objects.
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 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: 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 2: getDocument & getPageOctober 19, 2026
- PDF.js Deep Dive 1: The Architecture of PDF.jsOctober 16, 2026