StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: jbig2_stream

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: jbig2_stream

Introduction

This is the second article in the "Image Decoding" layer, and its subject is Jbig2Stream in the worker-side src/core/jbig2_stream.js (104 lines).

The previous article, jpeg_stream, handled continuous-tone DCT codestreams; this one handles bi-level images. JBIG2 is defined by ITU-T T.88 and can be seen as the successor to fax coding (CCITT G3/G4): it targets black-and-white scanned pages and uses symbol dictionaries, arithmetic coding and region composition to squeeze 1-bit pixels into a very small volume. The text layer of a scanned PDF, fax archives and some ebooks often sit on top of a JBIG2 codestream.

Its PDF filter name is /JBIG2Decode, and the entry point again comes from Parser.makeFilter:

        case "JBIG2Decode":
          return new Jbig2Stream(stream, maybeLength, params);

PDF.js does not implement JBIG2 from scratch in JavaScript. It compiles PDFium's decoder to WASM and provides a pure-JS fallback. That choice is pragmatic: JBIG2's arithmetic coding and symbol-dictionary composition are far more complex than JPEG's Huffman plus IDCT, and a pure-JS implementation would be both slow and hard to maintain. The price is that decoding becomes asynchronous, so the whole stream class has to be redesigned around Promises.

Compared with JPEG, three extra complications appear:

  1. JBIG2 has a standalone file header, while PDF embeds a headerless segment stream; some generators still write the header, and it must be stripped before decoding.
  2. An image's symbol dictionary may live in another stream, referenced through /JBIG2Globals in /DecodeParms. The main codestream and the global segment must be fetched separately, stripped separately, and handed to the decoder together.
  3. The real decoder is an asynchronous WASM singleton, and the first call also has to load jbig2.wasm (or the pure-JS fallback module). The synchronous getBytes path does not work here.

In one sentence, its job is: fetch the main codestream and the global segments, strip any JBIG2 file header, hand the bytes, dictionary dimensions and global segments to the WASM decoder, and asynchronously obtain a 1-bit pixel buffer.

Module Overview

jbig2_stream.js is only 104 lines; the body is one class with five methods and two getters:

BlockKey membersResponsibility
ConstructionconstructorStores the upstream stream, image dictionary, length and DecodeParms
Byte entrybytes, ensureBufferReads compressed bytes lazily and declares that no incremental buffer is used
Type markersisAsyncDecoder, isImageStreamDeclares asynchronous decoding and the image identity
PreprocessingstripFileHeaderStrips the JBIG2 file header, shared by the main codestream and globals
DecodingdecodeImageFetches globals, calls the WASM decoder and writes the buffer asynchronously
Async capabilitycanAsyncDecodeImageFromBufferDelegates "can bytes be fetched asynchronously first" to the upstream stream

It shares the same template with JpegStream for embedding a whole-image decoder into the stream interface: a lazy bytes, a no-op ensureBuffer, canAsyncDecodeImageFromBuffer delegated to upstream, and isImageStream always true. The difference is that the decoding function returns a Promise, so this class cannot use synchronous getBytes.

Three boolean markers answer three different questions and must be read separately:

MarkerQuestion it answersImplementation in this class
canAsyncDecodeImageFromBufferCan the compressed bytes be fetched asynchronously firstDelegates to this.stream.isAsync
isAsyncDecoderDoes the decoding function itself return a Promisetrue
isImageStreamIs this an image stream or a page content streamtrue

Together they produce the two branches of DecodeStream.getImageData: when upstream is asynchronous, asyncGetBytes first and then decode; when upstream is synchronous, isAsyncDecoder is true, so decodeImage is called directly and the Promise awaited. Synchronous getBytes is never selected by the image pipeline.

stripFileHeader is static because it depends on no instance state: the main codestream and the global segment are just two byte arrays with identical header rules. Being static also makes it independently testable and avoids constructing a temporary stream object for the global segment.

Inheritance and Construction

The class declaration corresponds almost line for line with JpegStream:

class Jbig2Stream extends DecodeStream {
  constructor(stream, maybeLength, params) {
    super(maybeLength);
 
    this.stream = stream;
    this.dict = stream.dict;
    this.maybeLength = maybeLength;
    this.params = params;
  }

The constructor Method

The four fields mean the same as in the previous article, but each is used differently:

  • stream is the upstream stream, possibly an in-memory Stream or a network chunked stream. Both bytes and asyncGetBytes take data from it.
  • dict is the image dictionary. JBIG2's /Width and /Height do not come from the codestream but are read from here; more on that below.
  • maybeLength comes from /Length; null means read to upstream EOF. A JBIG2 segment stream has no EOI like JPEG, so reaching the end means the data is complete.
  • params is /DecodeParms. /JBIG2Globals hangs off it, and it is the input that distinguishes this class from JPEG.

super(maybeLength) likewise computes minBufferLength, but ensureBuffer is a no-op, so that growth logic never runs.

The Lazy bytes Getter

Compressed bytes are still read lazily through a getter and cached:

  get bytes() {
    // If `this.maybeLength` is null, we'll get the entire stream.
    return shadow(this, "bytes", this.stream.getBytes(this.maybeLength));
  }

shadow runs getBytes on first access and then replaces the getter with an own data property of the same name, so later reads never touch upstream. As with JPEG there is a synchronous side effect: if a chunked network stream has not finished downloading, getBytes throws MissingDataException. DecodeStream.getImageData calls asyncGetBytes first when canAsyncDecodeImageFromBuffer is true, precisely to bypass that synchronous read.

ensureBuffer, isAsyncDecoder and isImageStream

The next three members are short, yet they decide whether this class can be consumed at all:

  ensureBuffer(requested) {
    // No-op, since `this.readBlock` will always parse the entire image and
    // directly insert all of its data into `this.buffer`.
  }
 
  get isAsyncDecoder() {
    return true;
  }
 
  get isImageStream() {
    return true;
  }

The no-op ensureBuffer follows the image-stream convention: reserve no space for incremental writes, since the whole image is decoded once and then replaces buffer.

One detail is worth calling out: the comment says readBlock parses the whole image, but Jbig2Stream does not override readBlock. The base class implementation is unreachable("Abstract method ..."). In other words, this class is by design incompatible with synchronous getBytes; if any call chain really reaches readBlock, it throws immediately. What actually steers the image pipeline away from synchronous reads is isAsyncDecoder.

isAsyncDecoder defaults to false in BaseStream. It tells DecodeStream.getImageData: even if upstream bytes are ready, go through decodeImage and await it instead of getBytes. The reason is direct: WASM module loading and decode are both Promises, and there is no way to disguise them as a synchronous byte stream.

isImageStream separates this stream from page content streams. StreamsSequenceStream filters it out when concatenating content, and PDFPage.getContentStream will not parse pixels as operators. JBIG2 streams need this marker especially, because they do not even support synchronous reads; treating one as an ordinary stream throws immediately.

Design note: JPEG disguises the asynchronous world as a synchronous one through readBlock; JBIG2 does not disguise anything and declares isAsyncDecoder so the upper layer takes the asynchronous entry. The same template forks on one question: whether the decoder can be driven synchronously. Admitting a limitation is safer than inventing a fake synchronous interface: the former shows up at the type level, the latter blows up only at runtime.

stripFileHeader: Stripping the Standalone File Header

The stripFileHeader Method

JBIG2's standalone file format is not the same as the segment stream embedded in PDF. Annex D.4 of T.88 defines the file header, and PDF normally stores the "embedded" organisation without it:

  // The JBIG2 file header is defined in ITU-T T.88, Annex D.4:
  // https://www.itu.int/rec/T-REC-T.88
  static stripFileHeader(bytes) {
    if (
      bytes.length >= 9 &&
      bytes[0] === 0x97 &&
      bytes[1] === 0x4a &&
      bytes[2] === 0x42 &&
      bytes[3] === 0x32 &&
      bytes[4] === 0x0d &&
      bytes[5] === 0x0a &&
      bytes[6] === 0x1a &&
      bytes[7] === 0x0a
    ) {
      const headerLength = (bytes[8] & 2) === 0 ? 13 : 9;
      return bytes.subarray(headerLength);
    }
    return bytes;
  }

The header layout can be written as a table:

OffsetLengthContent
0 to 78Magic bytes 0x97 0x4A 0x42 0x32 0x0D 0x0A 0x1A 0x0A
81Flags, where the bit selected by mask 2 marks an unknown page count
9 to 124Page count, present only when the mask 2 bit is 0

Three checks:

  1. At least 9 bytes. Magic plus flags is 9 bytes; a shorter input cannot carry a legal header and is returned unchanged.
  2. Byte-by-byte magic comparison. Eight constants spell the JBIG2 signature; any mismatch means a plain segment stream and no slicing. TextDecoder or string comparison is avoided here to keep the byte layer free of encoding overhead.
  3. Flags decide 9 or 13. If the bit selected by mask 2 in bytes[8] is 0, the header records the page count and four more bytes follow, for 13 in total; if it is 1, the page count is unknown and the header is only 9 bytes. The source writes (bytes[8] & 2) === 0 ? 13 : 9, and the order of the bit test and the ternary is easy to read backwards.

The return value uses subarray, a view rather than a copy. This matches JPEG's #skipUselessBytes: stripping only moves the read start, it does not move data. Both the main codestream and the global segment call this static method, so the two implementations cannot diverge.

The header must be stripped because the WASM side's _jbig2_decode expects a segment stream: it parses segment headers, region information and symbol dictionaries itself and does not expect another header in front. Leaving the header in misaligns the first segment header and corrupts every segment after it.

Segment Stream Organization

Below the file header come consecutive segments. Each segment consists of a segment header and segment data; the header records the segment number, segment type, page association, referred-to segment list and data length. The type decides whether the body is page information, a symbol dictionary, an intermediate region or a text region; the page association says which page it belongs to; the referred-to list lets symbol dictionaries be reused across segments.

FieldRoleRelationship to PDF
Segment numberUniquely identifies a segmentDictionaries in globals are referenced by segment number
FlagsSegment type, page association length, reference countDetermines how the rest of the segment header is parsed
Page associationWhich page the segment belongs toMulti-page codestreams are split by it
Data lengthByte count of the segment bodyA wrong length makes the whole segment fail to parse

stripFileHeader touches none of these fields; it only cuts the header off, and all segment-header parsing is left to WASM. The stream layer does not reimplement the specification — the boundary is clean: PDF compatibility issues are solved in JavaScript, JBIG2 syntax issues are solved in the decoder.

decodeImage: Three Inputs to WASM

The decodeImage Method

The real decoding entry is asynchronous:

  async decodeImage(bytes, length, _decoderOptions) {
    if (this.eof) {
      return this.buffer;
    }
    bytes = Jbig2Stream.stripFileHeader(bytes || this.bytes);
 
    let globals = null;
    if (this.params instanceof Dict) {
      const globalsStream = this.params.get("JBIG2Globals");
      if (globalsStream instanceof BaseStream) {
        globals = Jbig2Stream.stripFileHeader(globalsStream.getBytes());
      }
    }
    this.buffer = await JBig2CCITTFaxImage.instance.decode(
      bytes,
      this.dict.get("Width"),
      this.dict.get("Height"),
      globals
    );
    this.bufferLength = this.buffer.length;
    this.eof = true;
 
    return this.buffer;
  }

Piece by piece:

  1. The eof guard. As with JPEG, a finished decode returns the existing buffer. The difference is that this buffer comes from WASM and its type is not exactly a plain Uint8Array, but the upper layers only interpret it by byte length and bit depth.
  2. Strip the main codestream. bytes || this.bytes lets the asynchronous path pass bytes already fetched; otherwise the lazy getter runs. Whatever the source, stripFileHeader is applied first.
  3. Globals are optional. /JBIG2Globals exists only when params is a Dict; the value must be a BaseStream or it counts as no globals. The global segment may itself carry a file header, so it is stripped once too.
  4. Dictionary dimensions. Width and Height are read from this.dict, not from params, and are not guessed from JBIG2 segment headers. The PDF image dictionary is the authoritative source for final size; region information in the codestream only describes how the image is composed.
  5. WASM decoding. JBig2CCITTFaxImage.instance is a shadow-cached singleton, and decode takes the main codestream, width, height and globals and returns a Promise.
  6. Commit the result. buffer is replaced, bufferLength takes its length and eof is set. Both length and _decoderOptions are ignored: JBIG2 cannot decode half at a requested length, and it has no tunable decoding options.

Note that eof = true runs only after the await. It is not a concurrency lock: if two callers enter before decoding finishes, both see eof as false. In practice one getImageData drives one image, but the detail shows that "idempotent" only guarantees repeated reads do not decode twice, not that concurrent calls decode once.

JBIG2Globals and Global Segments

A JBIG2 codestream is made of segments: page information, symbol dictionaries, intermediate regions, text regions and so on. Global segments hold symbol dictionaries and generic regions reusable across pages. PDF splits them into two streams:

  • the image stream itself holds only the segments of the current page;
  • /JBIG2Globals in /DecodeParms points at another stream holding the shared segments.

The decoder must see both to map symbol numbers back to real bitmaps. The source fetches them directly:

if (this.params instanceof Dict) {
  const globalsStream = this.params.get('JBIG2Globals');
  if (globalsStream instanceof BaseStream) {
    globals = Jbig2Stream.stripFileHeader(globalsStream.getBytes());
  }
}

Three constraints hide in four short lines:

  • Only BaseStream is accepted. /JBIG2Globals may be missing, may be null, or may be written as some other type in a damaged file; all these degrade to "no global segments" instead of throwing.
  • Synchronous fetch. globalsStream.getBytes() takes no length and reads to that stream's EOF. The global segment is usually small and is fetched together with the image dictionary, so no second asyncGetBytes is made here.
  • Globals are stripped too. Some generators store the global segment as a complete JBIG2 file, and that file header also does not belong to a segment stream. Main codestream and global segment share stripFileHeader, keeping both inputs in the same organisation.

If the globals are missed, a JBIG2 image with symbol dictionaries decodes to a pile of blanks or fails outright: the decoder reads symbol numbers inside text regions but cannot find the corresponding bitmaps. The field is optional in the PDF specification, but it is very common in scanned documents.

The Authoritative Source of Width and Height

The second and third arguments to decode are this.dict.get("Width") and this.dict.get("Height"). This differs from JPEG: a JPEG SOF segment carries its own dimensions, which JpegImage can read from the codestream; a JBIG2 segment stream describes regions and composition, and the final bitmap size is specified by the PDF image dictionary.

Three situations follow:

Dictionary dimensionsCodestream contentResult
Match the page information segmentNormal single pageOutput at dictionary size, the most common case
Larger than the actual regionsMultiple regions or blank paddingThe decoder lays out regions at the given size and leaves the remainder blank
Missing or not a positive integerAnythingWASM receives an invalid size, decoding fails, and the image pipeline handles it

The source performs no type validation here and passes the result of dict.get straight through. Validation belongs to Parser and PDFImage: Width and Height are required in an image dictionary, and when damage removes them the failure happens inside the decoder rather than in the stream layer.

The WASM Singleton and Output Format

JBig2CCITTFaxImage extends WasmImage and loads jbig2.wasm; when the environment lacks WASM it falls back to jbig2_nowasm_fallback.js. instance is a shadow-cached singleton, and the whole worker shares one module instance:

  • the first call loads and initialises the module;
  • each decode copies the main codestream and global segments into the WASM heap with _malloc;
  • _jbig2_decode runs, and the C side performs arithmetic decoding, symbol composition and bitmap assembly;
  • the result is written to module.imageData and copied into a JavaScript Uint8ClampedArray;
  • finally releases both heap blocks, preventing accumulation across documents.

Output is 1-bit packed pixel rows, not expanded RGBA. This aligns with PDF's /BitsPerComponent 1 and /ColorSpace /DeviceGray: the worker keeps the compact format and expands only when PDFImage decides how to draw.

WasmImage.cleanup can discard the module Promise during document cleanup, but the singleton object itself remains; the next decode reinitialises. This lifecycle is driven by clearGlobalCaches, called from entry points such as Catalog.cleanup, and is outside this file's responsibility.

How 1-bit Pixels Are Consumed

When PDFImage.createImageData receives this stream, because image instanceof DecodeStream it reuses buffer directly and does not expand to RGBA in the worker; when inversion is needed it only XORs the 1-bit buffer once. The bitmap kind is marked ImageKind.GRAYSCALE_1BPP, with one byte carrying eight pixels.

If the worker supports OffscreenCanvas, createBitmap expands to RGBA with convertToRGBA and then calls transferToImageBitmap; otherwise the 1-bit buffer and kind are sent to the main thread and expanded there. Both paths separate "decompression" from "colouring": WASM produces only the bit plane, and colour space and inversion wait until drawing. For a full-page scan, this saves the intermediate memory of a whole RGBA image.

Design note: decodeImage completes all three of "format preprocessing", "optional inputs" and "authoritative dimensions" in the stream layer, and the WASM side receives only a clean segment stream, a width and height, and global segments. The narrower the boundary, the less the decoder needs to understand how PDF files break; conversely, PDF compatibility logic never seeps into C code.

Asynchronous Byte Fetching

The canAsyncDecodeImageFromBuffer Method

The last method is identical to JPEG:

  get canAsyncDecodeImageFromBuffer() {
    return this.stream.isAsync;
  }

It only answers "can upstream bytes be fetched asynchronously", not "is decoding asynchronous". Combined, DecodeStream.getImageData has two paths:

Upstream isAsyncisAsyncDecoderBehaviour of getImageData
truetrueawait asyncGetBytes first, then await decodeImage
falsetrueawait decodeImage(null, ...) directly
falsefalseSynchronous getBytes, looping over readBlock

For this class the third row never occurs, because isAsyncDecoder is always true; forcing getBytes would land in the base class's unimplemented readBlock. The second row is the local-file case: bytes are ready, but decoding is still a Promise and must be awaited. The first row is the network case: fetch all compressed bytes first, so decoding does not run out of data halfway.

There is no MissingDataException retry logic here, because decodeImage is not a resumable parser. Once WASM starts reading heap memory there is no "pause and continue" interface; fetching all bytes is the only correct approach.

That is also why this class provides no readBlock: the synchronous interface requires a block decoder that returns immediately, while a WASM call must wait for module loading and Promise settlement. Rather than write a fake implementation that throws, let isAsyncDecoder guide callers to the right entry.

isImageStream and Its Consumers

The three-line isImageStream implementation matches JPEG, but it matters more for JBIG2:

  get isImageStream() {
    return true;
  }

Only two places in the repository consume it:

  • StreamsSequenceStream filters image streams when constructing, so pixels are never glued into a content stream;
  • PDFPage.getContentStream excludes image streams, so JBIG2 bytes are never parsed as page operators.

A JBIG2 stream has no usable readBlock; read as an ordinary stream, it immediately throws "abstract method called". isImageStream blocks that misuse at the type-check stage instead of letting it explode at runtime.

Four Common Misreadings

  • Treating /JBIG2Globals as part of the main codestream. It is a separate stream referenced by /DecodeParms, and must be fetched alone, stripped alone, and passed as the fourth argument to the decoder.
  • Assuming stripFileHeader only handles the main codestream. The global segment may also carry a complete JBIG2 file header, and the source treats both inputs the same way.
  • Seeing a no-op ensureBuffer and assuming synchronous getBytes works. readBlock is not overridden, so a synchronous read lands on the base class's unreachable; the correct entry is getImageData, branched by isAsyncDecoder.
  • Treating Width and Height as /DecodeParms fields. They live in the image dictionary, the source reads them from this.dict, and params only handles /JBIG2Globals.

Comparison with jpeg_stream

DimensionJpegStreamJbig2Stream
Filter/DCTDecode, /DCT/JBIG2Decode
DecoderPure-JS JpegImageWASM singleton JBig2CCITTFaxImage
Decoding modeSynchronousAsynchronous
Synchronous readAvailable, readBlock triggers whole-image decodingUnavailable, readBlock not overridden
File headerOnly junk bytes before SOI are skippedA 9- or 13-byte header is stripped according to the flags
Extra inputs/ColorTransform/JBIG2Globals global segments
Size sourcedrawWidth, drawHeight, scalableDictionary Width, Height, not scalable
Output1, 3 or 4 channel pixels1-bit packed pixels
Native accelerationOptional ImageDecoder pathNone, always WASM

Read together, the two articles show that "lazy bytes + no-op ensureBuffer + delegated async capability + isImageStream" is the standard template for image streams; only the decoder interface and the format preprocessing really differ. Separating the template from the differences means that reading a new filter requires answering just three questions: where the bytes come from, whether the decoder is synchronous or asynchronous, and whether the codestream has a format header in front.

Conclusion

Jbig2Stream is the adapter of the bi-level image decoding layer: it turns PDF's two streams (main codestream and global segments) into one clean segment-stream input, strips file headers that do not belong to a segment stream, and then hands width, height and global segments to the asynchronous WASM decoder.

Where It Sits in the Overall Chain

StageModuleInputOutput
FilterParser.makeFilter/JBIG2Decode, stream dictionaryJbig2Stream
Byte fetchbytes, canAsyncDecodeImageFromBufferUpstream Stream or chunked streamComplete main codestream bytes
PreprocessingstripFileHeaderMain codestream, global segmentsHeaderless segment stream
DecodingdecodeImage → JBig2CCITTFaxImage.instanceSegment stream, Width, Height, globals1-bit pixel buffer
Image wrappingPDFImage.createImageData1-bit bufferImageBitmap
DrawingCanvasGraphicsImageBitmapImage on the canvas

In one sentence: Parser wraps /JBIG2Decode in a Jbig2Stream, the stream layer strips headers and gathers globals, WASM turns the segment stream into a 1-bit bitmap, and PDFImage decides how to expand and draw it.

Five Design Principles

  1. Reuse the image-stream template and replace only the decoder. Lazy bytes, a no-op ensureBuffer, canAsyncDecodeImageFromBuffer delegated to upstream and an always-true isImageStream are identical to JPEG; a new format only needs its own preprocessing and decoding call.
  2. Format headers are dealt with before the decoder. stripFileHeader serves both the main codestream and global segments, cuts exactly 9 or 13 bytes according to the flags and uses subarray to avoid copying. The decoder therefore only needs to understand one input: the segment stream.
  3. Optional inputs degrade explicitly. When /JBIG2Globals is missing or of the wrong type, globals stays null rather than throwing. An image with symbol dictionaries then fails, but an image without them still decodes, keeping the tolerance window minimal.
  4. Asynchronous capability must be declared explicitly. isAsyncDecoder routes the upper layer to getImageData instead of getBytes, avoiding the unimplemented readBlock. Not faking a synchronous interface exposes the limitation at the type level rather than at runtime.
  5. Intermediate results stay compact. WASM outputs 1-bit packed pixels and does not expand to RGBA early in the worker; the real expansion waits until PDFImage chooses the drawing path. The larger the pixels, the more memory this principle saves.

This completes the "how JBIG2 becomes a bi-level bitmap" link. Two items remain in the image decoding layer — JPX (JPEG 2000) and CCITT fax: the former is also an asynchronous WASM decoder, the latter is a pure-JS bitstream decoder, but both follow the template in this article. After these three, reading how PDFImage interprets pixels according to ColorSpace, masks and BitsPerComponent closes the loop.

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