StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: predictor_stream

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: predictor_stream

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 parsing

The 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:

BlockKey methodsResponsibility
Factory and validationconstructorReturn the original stream when there is no prediction; validate Predictor; compute row and pixel widths
TIFF predictionreadBlockTiffHorizontal differencing for Predictor 2, covering 1, 8, 16 bits and generic bit depths
PNG predictionreadBlockPngThe five row filters of Predictor 10 to 15
Inherited facilitiesDecodeStreambuffer, 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 rowBytes is known, each readBlock handles 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:

ConditionReturn valueMeaning
params is not a dictionarystrNo /DecodeParms, no prediction needed
Predictor missing or at most 1strPrediction declared but not enabled in practice
Parameters validthisPrediction 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:

ValueMeaningHandling in this version
1No predictionReturn the original stream
2TIFF predictor 2readBlockTiff
10PNG NonereadBlockPng
11PNG SubreadBlockPng
12PNG UpreadBlockPng
13PNG AveragereadBlockPng
14PNG PaethreadBlockPng
15PNG optimumreadBlockPng
OtherUndefinedThrows 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:

FieldFormulaMeaning8-bit RGB example
pixBytes(colors * bits + 7) >> 3Bytes occupied by one pixel3
rowBytes(columns * colors * bits + 7) >> 3Bytes occupied by a row3 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, bits and columns, 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:

ConditionLoop unitModulo handlingOutput length
1-bit monochromeBytePrefix XORrowBytes
8-bitByteAutomatic truncation by Uint8ArrayrowBytes
16-bitTwo bytesWrite only the low 16 bitsrowBytes
OtherSamplebitMask maskingrowBytes

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:

ValueNameRestore formulaHandling of the first pixel
0NoneRaw valueNone needed
1SubRaw value plus left neighbourNo left neighbour; copy the first pixBytes bytes
2UpRaw value plus up neighbourEquivalent to None when the previous row is all zero
3AverageRaw value plus the floor of the average of left and upLeft treated as 0; add half of up only
4PaethRaw value plus whichever of left, up and up-left is closest to the predictorLeft and up-left treated as 0; equivalent to adding up
OtherIllegalNoneThrows 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: prevRow reuses 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:

DimensionTIFF predictor 2PNG predictor 10 to 15
First byte of the rowNone1 filter-type byte per row
Prediction directionLeft neighbour onlyLeft, up, up-left and combinations
Needs the previous rowNoUp, Average and Paeth do
First-row special caseNone (left treated as 0)prevRow all zero, no branch
Bit depths supported1, 8, 16 and genericByte-oriented with pixBytes stride
Input row lengthrowBytesrowBytes plus 1 filter byte
Code organisationFour branches by bit depthFive 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

StageModuleInputOutput
Byte supplyStream / ChunkedStreamFile slices, network chunksCompressed bytes
Entropy decodingFlateStream / LZWStreamzlib and LZW streamsPrediction residuals
Row predictionPredictorStreamPredictor, Colors, BPC, ColumnsRows of original samples
Upper consumptionImage decoding / xref parsingRow bytesPixels, 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

  1. 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.
  2. Metadata is computed once. pixBytes and rowBytes are folded at construction, and the decode loops use only those two integers, fully separating format parsing from data movement.
  3. Decoder selection happens once. readBlock is bound to the TIFF or PNG variant at construction, so the hot path never repeats the check.
  4. The previous row is a view, not a copy. prevRow points 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