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

Introduction
This is the second article in the "Bytes and Streams" layer, and its subject is the worker-side src/core/predictor_stream.js.
The previous article's FlateStream turned a DEFLATE bit stream back into a byte sequence. But a byte sequence is not the same as usable data: to squeeze out more compression, image pixels and cross-reference streams first apply differencing "within a row and between neighbouring rows" and only then hand the result to Flate. After decompression the differencing must be undone, and that work is done by PredictorStream.
Why does this deserve a layer of its own? Because neighbouring bytes in bitmaps and cross-reference records are highly correlated: adjacent pixels in a row are usually close, and bytes in the same column of two adjacent rows are often identical. After differencing, many bytes become 0 or a very small integer, so Flate's LZ77 and Huffman find repeats more easily. The decoder must restore strictly row by row, in order; get one row wrong and every later row that depends on it is shifted too — which is exactly why the predictor has to hold "one row at a time" state.
Its position in the chain is fixed: downstream of the filter chain, upstream of the real consumer.
Raw bytes
→ FlateStream / LZWStream (entropy decoding)
→ PredictorStream (row prediction restore, this file)
→ image decoding / xref record parsingThe code in Parser.makeFilter shows the relationship directly:
case "Fl":
case "FlateDecode":
if (params) {
return new PredictorStream(
new FlateStream(stream, maybeLength),
maybeLength,
params
);
}
return new FlateStream(stream, maybeLength);Whenever the dictionary has /DecodeParms, one more layer is wrapped around it; otherwise the stream goes straight to FlateStream. Note that params is often not empty — images and xref streams almost always carry Predictor, Columns, Colors and BitsPerComponent.
In the dictionaries of image XObjects and cross-reference streams, this group of parameters usually looks like:
/DecodeParms
<< /Predictor 12
/Columns 4
/Colors 1
/BitsPerComponent 8 >>This says each row has 4 8-bit samples and uses PNG Up prediction. For a cross-reference stream, /Columns should equal the sum of the three fields in the /W array, i.e. the total byte count of one record; for an image it might be /Predictor 15 /Colors 3 /BitsPerComponent 8 /Columns 1024, which is 3072 bytes per row with adaptive filtering per row.
PredictorStream understands none of the business meaning of these fields. It only treats Columns, Colors and BitsPerComponent as inputs to the row-width formula, and Predictor as the switch that selects a decoder.
In one sentence, its job: turn "rows of prediction residuals" back into "rows of original samples", and guarantee that the output byte count matches the original image or record.
The file is only 234 lines, with no lookup constants and no complicated state machine. All of its complexity lies in two groups of bitwise operations: the cumulative differencing of the TIFF predictor, and the five filters of the PNG predictor.
Module Overview
The file splits into four blocks:
| Block | Key methods | Responsibility |
|---|---|---|
| Factory and validation | constructor | Return the original stream when there is no prediction; validate Predictor; compute row and pixel widths |
| TIFF prediction | readBlockTiff | Horizontal differencing for Predictor 2, covering 1, 8, 16 bits and generic bit depths |
| PNG prediction | readBlockPng | The five row filters of Predictor 10 to 15 |
| Inherited facilities | DecodeStream | buffer, bufferLength, eof, ensureBuffer |
It extends DecodeStream with the same contract as FlateStream: implement one readBlock that appends "one row" of data to buffer each call. The upper getBytes triggers it on demand until enough bytes for the caller have accumulated.
Design note: the predictor's unit of input is the "row", not the "block" or the "whole stream". Once
rowBytesis known, eachreadBlockhandles a fixed length, so memory use is independent of image height — no matter how tall the image, only one extra reference to the previous row is needed.
Construction and Parameters
The constructor method
PredictorStream's constructor doubles as a factory: when the parameters say "no prediction needed", it does not return a wrapper but hands the original stream straight back.
class PredictorStream extends DecodeStream {
constructor(str, maybeLength, params) {
super(maybeLength);
if (!(params instanceof Dict)) {
return str; // no prediction
}
const predictor = (this.predictor = params.get("Predictor") || 1);
if (predictor <= 1) {
return str; // no prediction
}
if (predictor !== 2 && (predictor < 10 || predictor > 15)) {
throw new FormatError(`Unsupported predictor: ${predictor}`);
}
this.readBlock = predictor === 2 ? this.readBlockTiff : this.readBlockPng;
this.stream = str;
this.dict = str.dict;
const colors = (this.colors = params.get("Colors") || 1);
const bits = (this.bits = params.get("BPC", "BitsPerComponent") || 8);
const columns = (this.columns = params.get("Columns") || 1);
this.pixBytes = (colors * bits + 7) >> 3;
this.rowBytes = (columns * colors * bits + 7) >> 3;
return this;
}Three early returns
There are three exits in the constructor, with different meanings:
| Condition | Return value | Meaning |
|---|---|---|
params is not a dictionary | str | No /DecodeParms, no prediction needed |
Predictor missing or at most 1 | str | Prediction declared but not enabled in practice |
| Parameters valid | this | Prediction enabled; decode via the readBlock variant |
In JavaScript, a constructor that returns an object overrides the result of new; in a derived class, returning a non-object throws a TypeError. Both return str statements return objects, so they are legal. The cost is that super(maybeLength) has already run, and the discarded instance computed minBufferLength and initialised buffer and bufferLength — a few numbers, negligible, in exchange for callers not having to check "is the predictor enabled" at all.
makeFilter can therefore write new PredictorStream(...) without thinking; what comes back may be a wrapper or the original stream, and the interface is the same.
this.dict = str.dict is also worth noting: the wrapper does not create a new dictionary but shares the underlying stream's, so identity information such as objId is not lost. Upper layers doing caching or circular-reference detection still see the same object.
Parameter validation
predictor has only two legal shapes:
| Value | Meaning | Handling in this version |
|---|---|---|
| 1 | No prediction | Return the original stream |
| 2 | TIFF predictor 2 | readBlockTiff |
| 10 | PNG None | readBlockPng |
| 11 | PNG Sub | readBlockPng |
| 12 | PNG Up | readBlockPng |
| 13 | PNG Average | readBlockPng |
| 14 | PNG Paeth | readBlockPng |
| 15 | PNG optimum | readBlockPng |
| Other | Undefined | Throws Unsupported predictor |
The test is written as predictor !== 2 && (predictor < 10 || predictor > 15), excluding 3 to 9, anything above 16 and negative numbers in one go. A 0 has already been folded into 1 by the earlier || 1 and never reaches this line.
In the specification, 10 to 14 correspond to fixed PNG filter types, but each encoded row still carries a "filter type byte". This version does not check that the byte agrees with the declared Predictor; it reads row by row and dispatches on the byte uniformly:
this.readBlock = predictor === 2 ? this.readBlockTiff : this.readBlockPng;This line turns "which decoder to use" from a per-row branch into a method reference bound once at construction. readBlock is the hot path that DecodeStream.getBytes calls repeatedly, so one less check is one less property lookup.
The cost is a per-instance binding pointing at a prototype method, a tiny memory overhead; for an object created once per stream, the trade is clearly worth it.
Row-width computation
const colors = (this.colors = params.get('Colors') || 1);
const bits = (this.bits = params.get('BPC', 'BitsPerComponent') || 8);
const columns = (this.columns = params.get('Columns') || 1);
this.pixBytes = (colors * bits + 7) >> 3;
this.rowBytes = (columns * colors * bits + 7) >> 3;Colors is the number of components per pixel (1 for gray, 3 for RGB, 4 for CMYK), Columns is the number of pixels per row, and BitsPerComponent is the number of bits per component. Multiplying the three gives the bits in a row; adding 7 and shifting right by 3 gives the byte count rounded up. This is integer ceil division without floating point.
params.get("BPC", "BitsPerComponent") uses the dictionary's two-key fallback: look for /BPC first, then /BitsPerComponent. The former is an abbreviation used by some generators; the latter is the specification name.
The two widths mean different things:
| Field | Formula | Meaning | 8-bit RGB example |
|---|---|---|---|
pixBytes | (colors * bits + 7) >> 3 | Bytes occupied by one pixel | 3 |
rowBytes | (columns * colors * bits + 7) >> 3 | Bytes occupied by a row | 3 times columns |
pixBytes is used only in the PNG filters as the "left-neighbour stride". At bit depths below 8 it is at least 1, i.e. it falls back to a whole byte for the left neighbour; that case is rare on the image path, where real data is almost always 8 or 16 bits.
rowBytes is the fixed length read by every readBlock and the fixed stride of the output. It is computed once here and reused directly by every loop afterwards.
Design note: metadata is parsed once at construction and immediately reduced to two byte widths. The decode loops no longer contain a multiplication of
colors,bitsandcolumns, nor repeated ceiling division — a textbook separation of "format parsing" from "data movement".
TIFF Prediction
The readBlockTiff method
TIFF predictor 2 performs horizontal differencing only: each sample is added to the sample of the same component to its left. It needs no previous row, so the implementation has no state beyond the row buffer.
readBlockTiff() {
const rowBytes = this.rowBytes;
const bufferLength = this.bufferLength;
const buffer = this.ensureBuffer(bufferLength + rowBytes);
const bits = this.bits;
const colors = this.colors;
const rawBytes = this.stream.getBytes(rowBytes);
this.eof = !rawBytes.length;
if (this.eof) {
return;
}The order matters: ensureBuffer grows the buffer first, then the source data is read. Growing the buffer may reallocate the Uint8Array, so any reference to buffer must be established after the growth — the prevRow view in the PNG branch follows the same rule.
rawBytes may be shorter than rowBytes: a truncated underlying stream returns a short array. Only "not a single byte" counts as EOF here; a short row is still processed, and the missing positions are folded to 0 by the typed array later. This is leniency towards damaged files, not strict validation.
1-bit grayscale: one prefix XOR
The most common low-bit-depth case gets its own optimisation:
if (bits === 1 && colors === 1) {
// Optimized version of the loop in the "else"-branch
// for 1 bit-per-component and 1 color TIFF images.
for (i = 0; i < rowBytes; ++i) {
let c = rawBytes[i] ^ inbuf;
c ^= c >> 1;
c ^= c >> 2;
c ^= c >> 4;
inbuf = (c & 1) << 7;
buffer[pos++] = c;
}A row of 1-bit samples is a bit string where each bit is the cumulative XOR of all bits before it. Accumulating bit by bit with a naive loop would take 8 iterations per byte; here c ^= c >> 1, >> 2 and >> 4 form a parallel prefix XOR: three shifts propagate "the XOR of all preceding bits" to every position, doubling the stride each time, covering exactly 8 bits.
inbuf holds the carry across bytes. After a byte is decoded, its lowest bit is the last sample of the row; (c & 1) << 7 is placed in the high bit of inbuf, so the next byte's bit 7 can XOR onto it. The row thus stays continuous across bytes without any extra bit-state array.
Note that inbuf is 0 before the first byte, so the first sample of a row equals its raw value, equivalent to "left neighbour is 0".
The output has the same length as the input: each input byte yields one decoded byte and bufferLength grows by rowBytes, with no expansion for bit depth.
8 bits: first pixel passes through, the rest accumulate
} else if (bits === 8) {
for (i = 0; i < colors; ++i) {
buffer[pos++] = rawBytes[i];
}
for (; i < rowBytes; ++i) {
buffer[pos] = buffer[pos - colors] + rawBytes[i];
pos++;
}The first pixel has no left neighbour, so its colors bytes are copied directly. Every later byte adds the decoded value of "the same row, one pixel back, the same component". pos - colors refers to an already decoded value, not the raw residual — which is precisely why the loop must advance forward from the start of the row.
There is no & 0xff here. buffer is a Uint8Array, so assignment automatically takes the value modulo 256 and the overflow bits are dropped. One less bitwise operation is a real gain in a hot loop.
That i starts at colors rather than 0 also shows that this path assumes rowBytes is at least colors, i.e. columns is at least 1.
16 bits: big-endian addition
} else if (bits === 16) {
const bytesPerPixel = colors * 2;
for (i = 0; i < bytesPerPixel; ++i) {
buffer[pos++] = rawBytes[i];
}
for (; i < rowBytes; i += 2) {
const sum =
((rawBytes[i] & 0xff) << 8) +
(rawBytes[i + 1] & 0xff) +
((buffer[pos - bytesPerPixel] & 0xff) << 8) +
(buffer[pos - bytesPerPixel + 1] & 0xff);
buffer[pos++] = (sum >> 8) & 0xff;
buffer[pos++] = sum & 0xff;
}16-bit samples are stored big-endian: high byte first, low byte second. The addition reassembles both "the current sample" and "the sample of the same component to the left" into 16-bit integers, adds them, then splits the result back into two bytes.
sum can exceed 65535, but only the low 16 bits are written back (first the low 8 bits of sum >> 8, then the low 8 bits of sum), so the high bits are naturally dropped. This is modulo 65536 addition, as the predictor defines it.
The four & 0xff masks look redundant — a Uint8Array read is already 0 to 255. Masking before applying << 8 is defensive, keeping any sign bit out of the integer.
bytesPerPixel is colors * 2, the counterpart of colors in the 8-bit path.
Generic bit depth: unpack, accumulate, repack
All remaining combinations take the most general path:
} else {
const compArray = new Uint8Array(colors + 1);
const bitMask = (1 << bits) - 1;
let j = 0,
k = bufferLength;
const columns = this.columns;
for (i = 0; i < columns; ++i) {
for (let kk = 0; kk < colors; ++kk) {
if (inbits < bits) {
inbuf = (inbuf << 8) | (rawBytes[j++] & 0xff);
inbits += 8;
}
compArray[kk] =
(compArray[kk] + (inbuf >> (inbits - bits))) & bitMask;
inbits -= bits;
outbuf = (outbuf << bits) | compArray[kk];
outbits += bits;
if (outbits >= 8) {
buffer[k++] = (outbuf >> (outbits - 8)) & 0xff;
outbits -= 8;
}
}
}This block maintains two bit buffers at once: inbuf and inbits handle unpacking on the input side, outbuf and outbits handle repacking on the output side. compArray[kk] holds the "previous decoded sample in the current row" for each component, accumulated per component so components do not bleed into each other when colors is greater than 1. The array is allocated with length colors + 1; the extra slot is never used and is a defensive allocation.
Input side: when the remaining bits are fewer than one sample, a byte is read and appended to the low end of inbuf; a sample is taken with inbuf >> (inbits - bits), slicing bits bits from the high end of the live bits. inbuf is never cleaned of consumed high bits; the extracted result keeps only the low bits bits via bitMask, so stale bits are masked away. inbits is only reduced after a load and always stays below "sample width plus 8", so at most two bytes ever need buffering.
Output side: samples are appended to outbuf at bits width, and a byte is emitted whenever 8 bits have accumulated. Since the whole loop advances sample by sample over columns and colors, the output byte count is exactly rowBytes.
if (outbits > 0) {
buffer[k++] = (outbuf << (8 - outbits)) + (inbuf & ((1 << (8 - outbits)) - 1));
}If the row does not end on a byte boundary, the remaining samples are shifted up to the high bits of a byte, and the leftover low bits from the input buffer are merged in. The two bit ranges do not overlap, so + is equivalent to bitwise OR. That makes the last output byte identical to the encoder's and avoids zero-padding that would break alignment with the following row.
this.bufferLength += rowBytes; is committed once outside the branches. Whichever path ran, a decoded row is always rowBytes long.
The four paths compare side by side:
| Condition | Loop unit | Modulo handling | Output length |
|---|---|---|---|
| 1-bit monochrome | Byte | Prefix XOR | rowBytes |
| 8-bit | Byte | Automatic truncation by Uint8Array | rowBytes |
| 16-bit | Two bytes | Write only the low 16 bits | rowBytes |
| Other | Sample | bitMask masking | rowBytes |
Design note: the four paths are specialisations of one job. 1-bit uses prefix XOR, 8-bit relies on the typed array's automatic truncation, 16-bit splits bytes by hand, and the generic path unpacks and repacks. The point is not showing off, but giving the most common image formats (1-bit scans, 8-bit grayscale and RGB) the shortest loop.
PNG Prediction
The readBlockPng method
The PNG predictor has one more dimension than TIFF: it may use the previous row. The filter type byte at the start of the row decides how that row is restored.
readBlockPng() {
const rowBytes = this.rowBytes;
const pixBytes = this.pixBytes;
const predictor = this.stream.getByte();
const rawBytes = this.stream.getBytes(rowBytes);
this.eof = !rawBytes.length;
if (this.eof) {
return;
}
const bufferLength = this.bufferLength;
const buffer = this.ensureBuffer(bufferLength + rowBytes);
let prevRow = buffer.subarray(bufferLength - rowBytes, bufferLength);
if (prevRow.length === 0) {
prevRow = new Uint8Array(rowBytes);
}Where the previous row comes from
prevRow is not a separately stored array but the row immediately before in the already-decoded output buffer: buffer.subarray(bufferLength - rowBytes, bufferLength). Because every row appends exactly rowBytes bytes, this slice always points at the complete previous row.
On the first row bufferLength is 0 and subarray(-rowBytes, 0) is normalised to an empty view; the length === 0 check replaces it with an all-zero array. That way none of the five filters needs a special "is this the first row" test — with an all-zero previous row, Up degenerates naturally to None, and Paeth degenerates naturally to the form of Sub.
Reusing a view instead of copying has an obvious benefit: Paeth needs both the up and up-left values, so slicing out the previous row for every row would cost a full-row copy each time, while a view is just two numbers. The downside is a timing requirement — ensureBuffer must be called before the view is created, otherwise a reallocated buffer leaves the view pointing at old memory. The code fixes that order.
The filter type byte is read before the data. If the stream ends at a row boundary, getByte returns -1, then getBytes returns an empty array, eof is set and the function returns; the -1 is never used.
The five filters
The value of predictor is the first byte of the row and is unrelated to the Predictor 10 to 15 declared in the dictionary:
| Value | Name | Restore formula | Handling of the first pixel |
|---|---|---|---|
| 0 | None | Raw value | None needed |
| 1 | Sub | Raw value plus left neighbour | No left neighbour; copy the first pixBytes bytes |
| 2 | Up | Raw value plus up neighbour | Equivalent to None when the previous row is all zero |
| 3 | Average | Raw value plus the floor of the average of left and up | Left treated as 0; add half of up only |
| 4 | Paeth | Raw value plus whichever of left, up and up-left is closest to the predictor | Left and up-left treated as 0; equivalent to adding up |
| Other | Illegal | None | Throws Unsupported predictor |
Now the implementations, one by one.
case 0:
for (i = 0; i < rowBytes; ++i) {
buffer[j++] = rawBytes[i];
}
break;None is a whole-row pass-through. It still uses a loop, because the output must land in buffer rather than referring to rawBytes.
case 1:
for (i = 0; i < pixBytes; ++i) {
buffer[j++] = rawBytes[i];
}
for (; i < rowBytes; ++i) {
buffer[j] = (buffer[j - pixBytes] + rawBytes[i]) & 0xff;
j++;
}
break;Sub is isomorphic to the TIFF 8-bit path, only the stride changes from colors to pixBytes. The first pixBytes bytes have no left neighbour and are copied directly; the rest accumulate byte by byte.
case 2:
for (i = 0; i < rowBytes; ++i) {
buffer[j++] = (prevRow[i] + rawBytes[i]) & 0xff;
}
break;Up is the only filter where the whole row depends on the previous row, first pixel included. On the first row prevRow is all zero and the result is exactly the same as None.
case 3:
for (i = 0; i < pixBytes; ++i) {
buffer[j++] = (prevRow[i] >> 1) + rawBytes[i];
}
for (; i < rowBytes; ++i) {
buffer[j] =
(((prevRow[i] + buffer[j - pixBytes]) >> 1) + rawBytes[i]) & 0xff;
j++;
}
break;Average takes the midpoint of the left and up neighbours: (left + up) >> 1 rounds down. The first segment has no left neighbour, so only half of up is added (prevRow[i] >> 1). That segment has no & 0xff and relies on the Uint8Array's automatic modulo; the second segment masks explicitly — not perfectly consistent in style, but the results agree.
case 4:
// we need to save the up left pixels values. the simplest way
// is to create a new buffer
for (i = 0; i < pixBytes; ++i) {
up = prevRow[i];
c = rawBytes[i];
buffer[j++] = up + c;
}
for (; i < rowBytes; ++i) {
up = prevRow[i];
const upLeft = prevRow[i - pixBytes];
const left = buffer[j - pixBytes];
const p = left + up - upLeft;
let pa = p - left;
if (pa < 0) {
pa = -pa;
}
let pb = p - up;
if (pb < 0) {
pb = -pb;
}
let pc = p - upLeft;
if (pc < 0) {
pc = -pc;
}
c = rawBytes[i];
if (pa <= pb && pa <= pc) {
buffer[j++] = left + c;
} else if (pb <= pc) {
buffer[j++] = up + c;
} else {
buffer[j++] = upLeft + c;
}
}
break;Paeth is the only filter in the article that needs three neighbours. The predictor p equals left plus up minus up-left, then the distances of the three candidates to p are compared and the closest wins. pa, pb and pc correspond to left, up and up-left; Math.abs is expanded into two branches because this comparison runs on every byte of every row, avoiding a function call.
The selection order is pa, pb, pc from smallest upward, with ties going to left first, then up. That matches the PNG specification.
For the first pixBytes bytes of the first row, left and up-left count as 0, so p equals up and pb is 0 and smallest, which necessarily selects up — hence the code writes up + c directly. That is a correct specialisation.
The comment says "we need to create a new buffer", but the code does not — prevRow is already a view of the previous row inside the output buffer, and writing the current row does not overwrite it. The comment is a historical leftover; when reading, trust the code.
default:
throw new FormatError(`Unsupported predictor: ${predictor}`);The default here catches the in-row filter byte, not the Predictor validated at construction. Construction already restricted it to 10 to 15, but the first byte of each row comes from the data itself and may be 5 to 255 or -1. There is no leniency here: an unknown filter type means there is no restore formula for this row, and continuing would only produce wrong pixels.
this.bufferLength += rowBytes; closes it out, consistent with the TIFF path.
Design note:
prevRowreuses the previous row inside the output buffer, reducing the cost of the "previous row" dimension to one view object. At the same time the order "grow first, create the view after" must hold, or the view binds to a replaced array. No type system enforces this kind of ordering constraint; only the position of the code does.
TIFF vs PNG
Putting the two paths side by side, the differences are more structured than expected:
| Dimension | TIFF predictor 2 | PNG predictor 10 to 15 |
|---|---|---|
| First byte of the row | None | 1 filter-type byte per row |
| Prediction direction | Left neighbour only | Left, up, up-left and combinations |
| Needs the previous row | No | Up, Average and Paeth do |
| First-row special case | None (left treated as 0) | prevRow all zero, no branch |
| Bit depths supported | 1, 8, 16 and generic | Byte-oriented with pixBytes stride |
| Input row length | rowBytes | rowBytes plus 1 filter byte |
| Code organisation | Four branches by bit depth | Five branches by filter type |
The most important point: TIFF prediction unfolds along a single row, so after restoring the first pixel it can march right all the way; PNG's Up, Average and Paeth need "the same column of the previous row", so the decoded previous row must be kept. That is why the TIFF path has no prevRow at all, while the PNG path prepares it on entry.
Another easily missed point: PNG input is one byte longer than its output. getByte() reads off the filter type before getBytes(rowBytes); that byte never enters the output buffer and does not affect the advance of bufferLength.
Fault Tolerance and Edge Cases
This layer has no elaborate recovery mechanism; edge handling concentrates in three places.
First, truncation means EOF. this.eof = !rawBytes.length is set only when no data at all can be read. If only half a row is available, the loop still runs rowBytes times, rawBytes[i] at the missing positions is undefined, arithmetic with it yields NaN, and writing that to a Uint8Array turns it into 0. For a damaged file this is more practical than throwing: half a row is better than failing to render the whole page. It also means a short row is not treated as an error; only the upper image decoder, on a size mismatch, can notice.
Second, an illegal filter byte throws immediately. The default branch does not swallow the exception, because an unknown filter has no usable restore formula. This matches FlateStream's layered policy: structural problems (no data to read) are tolerated, semantic problems (an unrecognised data format) must surface.
Third, invalid parameters fail at construction. Predictor values 3 to 9 and anything above 16 are stopped by Unsupported predictor and never reach the decode loop. That way the switch in readBlockPng only has to handle 0 to 4, with no need to consider combinations such as "declared as 2 but took the PNG path".
There is one more implicit boundary: pixBytes and rowBytes both come from declared values. If Columns in the PDF does not match the actual data, decoding does not hang, but the output length gets out of step with the data — only upper layers can detect that from image dimensions or the length of an xref record. PredictorStream itself performs no consistency check; it only guarantees "advance by the declared row width".
Design note: the predictor does not guess, retry or repair. It treats the declared parameters as the single source of truth and only transforms bytes by formula. Wrong parameters naturally produce wrong output; deciding right from wrong is left to upper layers that hold more context.
Conclusion
PredictorStream is PDF.js's row-prediction restore layer: it turns the differenced bytes emitted by the filter chain back into original samples for image decoding and xref record parsing.
Where It Sits in the Overall Chain
| Stage | Module | Input | Output |
|---|---|---|---|
| Byte supply | Stream / ChunkedStream | File slices, network chunks | Compressed bytes |
| Entropy decoding | FlateStream / LZWStream | zlib and LZW streams | Prediction residuals |
| Row prediction | PredictorStream | Predictor, Colors, BPC, Columns | Rows of original samples |
| Upper consumption | Image decoding / xref parsing | Row bytes | Pixels, field records |
In one sentence: Flate turns "variable-length bits" into "fixed-length bytes", and Predictor turns "correlated residuals" into "independent samples".
Four Design Principles
- No prediction, no wrapper. The constructor returns the original stream, so callers never check "is there a predictor" and pay no forwarding cost for an empty shell object.
- Metadata is computed once.
pixBytesandrowBytesare folded at construction, and the decode loops use only those two integers, fully separating format parsing from data movement. - Decoder selection happens once.
readBlockis bound to the TIFF or PNG variant at construction, so the hot path never repeats the check. - The previous row is a view, not a copy.
prevRowpoints at the adjacent row inside the output buffer, minimising PNG prediction's spatial cost; the price is the fixed order "grow first, create the view after".
At this point the loop "what still has to happen after a stream is decompressed" is closed. Both ends of the filter chain are unfolded: FlateStream handles the bit stream and PredictorStream handles row differencing. Further down, the next step is how these bytes are interpreted as image pixels, font glyphs and cross-reference records.
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: 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 2: getDocument & getPageOctober 19, 2026
- PDF.js Deep Dive 1: The Architecture of PDF.jsOctober 16, 2026