StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: image_utils.js

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: image_utils.js

Introduction

This installment reads src/shared/image_utils.js as a complete 147-line source file.

The filename is broader than the implementation. This module does not detect image formats from bytes, parse CSS images, create object URLs, decode JPEG files, allocate canvases, or call putImageData(). Its four exports are synchronous, allocation-light pixel converters that write into caller-provided buffers.

Its only dependencies are FeatureTest and ImageKind from util.js:

  • ImageKind tells a dispatcher which source layout the caller has already identified;
  • FeatureTest.isLittleEndian lets the converters write four RGBA bytes with one native Uint32Array store.

The canvas connection is real but belongs to callers. display/canvas.js, core/image.js, and core/image_resizer.js use these functions to prepare ImageData; core/jpg.js uses the grayscale helper after JPEG decoding.

Module Overview

ExportLinesInputOutput and return value
convertToRGBA(params)18-27A params object tagged with kindDispatches to the 1-bit or RGB converter; otherwise returns null
convertBlackAndWhiteToRGBA(...)29-75Packed 1-bit pixelsWrites RGBA pixels; returns { srcPos, destPos }
convertRGBToRGBA(...)77-128Packed 8-bit RGB tripletsWrites opaque RGBA pixels; returns { srcPos, destPos }
grayToRGBA(src, dest)130-140One 8-bit gray sample per pixelWrites opaque RGBA pixels; returns nothing

Three source layouts reach four-byte RGBA output:

GRAYSCALE_1BPP:  1 bit per pixel, packed MSB first  → 8-bit RGBA
RGB_24BPP:       R, G, B bytes per pixel            → 8-bit RGBA, A = 255
8-bit gray:      one gray byte per pixel            → R = G = B = gray, A = 255

The module converts layouts, not color spaces. It performs no ICC transformation, gamma correction, resampling, or alpha compositing.

Shared Buffer and Endianness Model

Canvas ImageData.data is byte-oriented: each pixel occupies consecutive R, G, B, A bytes. These converters instead perform many writes through Uint32Array, where one array element covers one pixel.

That optimization depends on host byte order. PDF.js detects it once in FeatureTest.isLittleEndian by writing a byte and reading the same storage as a 32-bit word.

For opaque black, the in-memory bytes must always be:

00 00 00 ff   // R, G, B, A

The numeric value needed to produce those bytes differs:

Host orderNumeric wordUse in this module
Little-endian0xff000000black and the RGB alphaMask
Big-endian0x000000ffblack; RGB uses 0xff as its alpha mask

The same reasoning applies to grayscale and RGB packing. Endianness branches are not changing the canvas byte layout; they ensure that different CPU word layouts produce the same RGBA bytes.

These are internal buffer routines rather than general converters for arbitrary typed-array views. Several paths construct views from .buffer, so callers must provide the expected backing-buffer layout and alignment. Output buffers remain caller-owned.

convertToRGBA(): Dispatch by ImageKind, Not Image Detection

function convertToRGBA(params) {
  switch (params.kind) {
    case ImageKind.GRAYSCALE_1BPP:
      return convertBlackAndWhiteToRGBA(params);
    case ImageKind.RGB_24BPP:
      return convertRGBToRGBA(params);
  }
 
  return null;
}

This is the file's only ImageKind branch. It inspects an existing tag; it does not infer a kind from magic bytes, dimensions, or buffer length.

ImageKind itself defines three values in util.js:

const ImageKind = {
  GRAYSCALE_1BPP: 1,
  RGB_24BPP: 2,
  RGBA_32BPP: 3,
};

Only the first two require expansion here. RGBA_32BPP already has the destination layout, so it intentionally has no conversion branch. Like an unknown kind, it makes convertToRGBA() return null. Callers that accept RGBA must handle it before or around this dispatcher rather than treating null as converted output.

The entire params object is forwarded. Each implementation destructures only the fields it understands, so fields such as inverseDecode affect the 1-bit path and are irrelevant to RGB.

convertBlackAndWhiteToRGBA(): Expanding Packed Bits

function convertBlackAndWhiteToRGBA({
  src,
  srcPos = 0,
  dest,
  width,
  height,
  nonBlackColor = 0xffffffff,
  inverseDecode = false,
}) {
  // ...
}

This converter expands a 1-bit source into one 32-bit destination element per pixel.

ParameterContract
srcPacked bits; bit 7 is the first pixel in each byte
srcPosByte index at which this invocation starts; defaults to 0
destDestination storage whose .buffer is viewed as Uint32Array
width, heightPixel dimensions; each row occupies ceil(width / 8) source bytes
nonBlackColorNative-endian 32-bit color used for the non-black mapping; defaults to opaque white
inverseDecodeSwaps the colors assigned to zero and one bits

The function creates a new 32-bit view with new Uint32Array(dest.buffer) and writes from destination element zero. It does not accept an initial destPos. The returned destPos is therefore the number of 32-bit pixels written, not a byte count.

Color Mapping and inverseDecode

const black = FeatureTest.isLittleEndian ? 0xff000000 : 0x000000ff;
const [zeroMapping, oneMapping] = inverseDecode ? [nonBlackColor, black] : [black, nonBlackColor];
const xorMask = zeroMapping ^ oneMapping;

The mapping is explicit:

inverseDecodeSource bit 0Source bit 1
falseblacknonBlackColor
truenonBlackColorblack

nonBlackColor is a packed numeric word, not a CSS color string. The default 0xffffffff is opaque white on either endianness. Mask callers also use 0, producing transparent zero RGBA bytes for that mapping.

Full Bytes, Row Remainders, and Short Input

const widthInSource = width >> 3;
const widthRemainder = width & 7;

A row has floor(width / 8) complete bytes and up to seven remaining pixels. The outer loop advances by rows; the inner loop emits eight pixels for every complete byte, then handles only widthRemainder bits from the final byte.

Every pixel starts at bit 7 and moves toward bit 0. For a complete byte, the eight assignments are unrolled rather than nested in another loop.

If a final partial byte is absent, this specific branch substitutes 255:

const elem = srcPos < srcLength ? src[srcPos++] : 255;

That is a local short-input policy for the remainder path, not general validation of the whole source buffer.

Branchless Bit Selection

One unrolled assignment demonstrates the mapping technique:

dest[destPos] = zeroMapping ^ (-((elem >> 7) & 1) & xorMask);

The extracted bit is either zero or one:

  • for 0, negation produces 0, the mask contribution is zero, and the result is zeroMapping;
  • for 1, negation produces -1, whose two's-complement bits are all set, so the expression selects zeroMapping ^ xorMask, which equals oneMapping.

This selects between two colors without a per-pixel conditional. The same pattern is used for all eight bits and the row remainder.

The function returns the next source byte index and next destination pixel index:

return { srcPos, destPos };

That cursor result lets canvas and resizing callers convert successive horizontal bands without reallocating the source.

convertRGBToRGBA(): Repacking RGB Triplets

function convertRGBToRGBA({ src, srcPos = 0, dest, destPos = 0, width, height }) {
  // ...
}

The RGB converter computes its source length as width * height * 3 and its output size as width * height opaque pixels. Unlike the 1-bit helper, its dest is already expected to be a Uint32Array, and it accepts an initial destPos measured in 32-bit destination elements.

srcPos, in contrast, is a byte offset. The two returned cursors deliberately use different units:

return { srcPos: srcPos + len, destPos };

One exact-source subtlety matters for offset calls: src32 starts at srcPos, but the scalar tail initializes j as i * 4 and uses srcPos + len as its limit. The fast and tail paths therefore do not form a uniform arbitrary-offset abstraction; non-zero srcPos behavior must be read from those indexes literally.

Three Source Words Become Four Destination Pixels

const len = width * height * 3;
const len32 = len >> 2;
const src32 = new Uint32Array(src.buffer, srcPos, len32);
const alphaMask = FeatureTest.isLittleEndian ? 0xff000000 : 0xff;

Three 32-bit loads cover 12 source bytes, which contain exactly four RGB pixels. Four 32-bit stores can then cover the corresponding 16 RGBA bytes.

The Uint32Array constructor receives srcPos as a byte offset into the underlying buffer, so this word path carries normal typed-array alignment requirements. len32 counts complete 32-bit words; len remains the authoritative number of RGB bytes.

The optimized loop requires three available words:

for (; i < len32 - 2; i += 3, destPos += 4) {
  // Read s1, s2, s3 and write four pixels.
}

It advances by 12 source bytes and four destination pixels per iteration. A final byte-wise loop handles pixels left outside that three-word path.

Little-Endian Shuffling

On a little-endian host, the source comments name the numeric words R2B1G1R1, G3R3B2G2, and B4G4R4B3. The stores preserve RGB order in memory while replacing or inserting the alpha byte:

dest[destPos] = s1 | alphaMask;
dest[destPos + 1] = (s1 >>> 24) | (s2 << 8) | alphaMask;
dest[destPos + 2] = (s2 >>> 16) | (s3 << 16) | alphaMask;
dest[destPos + 3] = (s3 >>> 8) | alphaMask;

The shifts move components across word boundaries. The alpha mask forces every output pixel's A byte to 255; there is no source alpha to preserve.

Big-Endian Shuffling and the Scalar Tail

The big-endian branch performs the mirror operation with opposite shifts. Its source words are named R1G1B1R2, G2B2R3G3, and B3R4G4B4, matching the other numeric layout while producing the same byte-oriented RGBA result.

Both branches finish with a component-wise loop for the remainder. The little-endian scalar store assembles a pixel as:

src[j] | (src[j + 1] << 8) | (src[j + 2] << 16) | alphaMask;

The big-endian store uses shifts of 24, 16, and 8 instead. This scalar path is easier to read; the word path exists to reduce per-component work for the bulk of an image.

grayToRGBA(): Replicating One Byte into Three Channels

function grayToRGBA(src, dest) {
  if (FeatureTest.isLittleEndian) {
    for (let i = 0, ii = src.length; i < ii; i++) {
      dest[i] = (src[i] * 0x10101) | 0xff000000;
    }
  } else {
    for (let i = 0, ii = src.length; i < ii; i++) {
      dest[i] = (src[i] * 0x1010100) | 0x000000ff;
    }
  }
}

This is the simplest exported function. It uses src.length, not width * height, as the pixel count and expects dest to be a Uint32Array with at least that many elements.

Multiplication replicates a gray sample without three separate shifts:

Little-endian: v * 0x0010101 → 0x00vvvvvv, then alpha → 0xffvvvvvv
Big-endian:    v * 0x1010100 → 0xvvvvvv00, then alpha → 0xvvvvvvff

In memory, both forms produce v, v, v, 255. Black and white remain black and white, and every intermediate sample becomes an equally valued RGB gray.

There is no params object, no returned cursor pair, and no new destination view. The function writes synchronously and returns undefined. core/jpg.js uses it for grayscale JPEG output, after the JPEG decoder has produced one byte per pixel.

The Real Canvas and Decoder Callers

The helpers stay independent of canvas APIs, but their buffer shapes are designed around the following consumers.

CallerConversion usedWhat the caller owns
display/canvas.js — putBinaryImageDataDirect 1-bit or RGB conversion by imgData.kindCreates a reusable chunk ImageData, converts each band, calls putImageData()
display/canvas.js — putBinaryImageMask1-bit conversion with nonBlackColor: 0Expands the mask on the slow path and paints chunk image data
core/image.js1-bit conversion or convertToRGBAHandles inverseDecode, creates ImageData, may transfer an ImageBitmap
core/image_resizer.jsconvertToRGBA for successive bandsCarries srcPos, samples converted pixels, and may create an OffscreenCanvas
core/jpg.jsgrayToRGBADecodes JPEG samples and allocates the final RGBA storage

The canvas chunking explains the cursor-based API. putBinaryImageData keeps one chunkImgData rather than allocating full-size RGBA output for every band:

({ srcPos } = convertBlackAndWhiteToRGBA({
  src,
  srcPos,
  dest,
  width,
  height: i < fullChunks ? FULL_CHUNK_HEIGHT : partialChunkHeight,
}));
 
ctx.putImageData(chunkImgData, 0, i * FULL_CHUNK_HEIGHT);

The module supplies only the middle conversion step. Source decoding before it and canvas operations after it remain in their respective modules, keeping this shared file usable from both worker-side and display-side code.

Likewise, there is no hidden CSS-image path here: no URLs, style rules, blobs, or image elements are read. ImageKind is a PDF.js buffer-layout tag, not a browser image-format detector.

Summary

image_utils.js is a focused set of packed-pixel-to-RGBA writers.

Four points capture its design:

  1. Dispatch is not detection. convertToRGBA() trusts params.kind, supports 1-bit grayscale and 24-bit RGB, and returns null for other kinds, including already-expanded RGBA.
  2. Each converter has a distinct buffer contract. The 1-bit path creates a destination Uint32Array view, RGB accepts source and destination cursors in different units, and grayscale iterates over src.length.
  3. Endianness is an implementation detail, not an output difference. Native 32-bit stores reduce component-wise work while preserving the same R, G, B, A byte sequence on both host orders.
  4. Canvas and decoding stay outside the module. Callers own ImageData, chunking, OffscreenCanvas, bitmap transfer, JPEG decoding, and putImageData().

Read this file as a small buffer-layout layer, not as a general image utility library. Its compact API is shaped by actual PDF.js pixel paths and by the cost of expanding large images component by component.

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