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

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:
ImageKindtells a dispatcher which source layout the caller has already identified;FeatureTest.isLittleEndianlets the converters write four RGBA bytes with one nativeUint32Arraystore.
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
| Export | Lines | Input | Output and return value |
|---|---|---|---|
convertToRGBA(params) | 18-27 | A params object tagged with kind | Dispatches to the 1-bit or RGB converter; otherwise returns null |
convertBlackAndWhiteToRGBA(...) | 29-75 | Packed 1-bit pixels | Writes RGBA pixels; returns { srcPos, destPos } |
convertRGBToRGBA(...) | 77-128 | Packed 8-bit RGB triplets | Writes opaque RGBA pixels; returns { srcPos, destPos } |
grayToRGBA(src, dest) | 130-140 | One 8-bit gray sample per pixel | Writes 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 = 255The 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, AThe numeric value needed to produce those bytes differs:
| Host order | Numeric word | Use in this module |
|---|---|---|
| Little-endian | 0xff000000 | black and the RGB alphaMask |
| Big-endian | 0x000000ff | black; 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.
| Parameter | Contract |
|---|---|
src | Packed bits; bit 7 is the first pixel in each byte |
srcPos | Byte index at which this invocation starts; defaults to 0 |
dest | Destination storage whose .buffer is viewed as Uint32Array |
width, height | Pixel dimensions; each row occupies ceil(width / 8) source bytes |
nonBlackColor | Native-endian 32-bit color used for the non-black mapping; defaults to opaque white |
inverseDecode | Swaps 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:
inverseDecode | Source bit 0 | Source bit 1 |
|---|---|---|
false | black | nonBlackColor |
true | nonBlackColor | black |
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 produces0, the mask contribution is zero, and the result iszeroMapping; - for
1, negation produces-1, whose two's-complement bits are all set, so the expression selectszeroMapping ^ xorMask, which equalsoneMapping.
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 → 0xvvvvvvffIn 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.
| Caller | Conversion used | What the caller owns |
|---|---|---|
display/canvas.js — putBinaryImageData | Direct 1-bit or RGB conversion by imgData.kind | Creates a reusable chunk ImageData, converts each band, calls putImageData() |
display/canvas.js — putBinaryImageMask | 1-bit conversion with nonBlackColor: 0 | Expands the mask on the slow path and paints chunk image data |
core/image.js | 1-bit conversion or convertToRGBA | Handles inverseDecode, creates ImageData, may transfer an ImageBitmap |
core/image_resizer.js | convertToRGBA for successive bands | Carries srcPos, samples converted pixels, and may create an OffscreenCanvas |
core/jpg.js | grayToRGBA | Decodes 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:
- Dispatch is not detection.
convertToRGBA()trustsparams.kind, supports 1-bit grayscale and 24-bit RGB, and returnsnullfor other kinds, including already-expanded RGBA. - Each converter has a distinct buffer contract. The 1-bit path creates a destination
Uint32Arrayview, RGB accepts source and destination cursors in different units, and grayscale iterates oversrc.length. - Endianness is an implementation detail, not an output difference. Native 32-bit stores reduce component-wise work while preserving the same
R, G, B, Abyte sequence on both host orders. - Canvas and decoding stay outside the module. Callers own
ImageData, chunking,OffscreenCanvas, bitmap transfer, JPEG decoding, andputImageData().
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
- PDF.js Deep Dive from Demo 10: CanvasGraphics and operatorList RenderingOctober 27, 2026
- PDF.js Deep Dive from Demo 9: Network Streams and Range RequestsOctober 27, 2026
- PDF.js Deep Dive from Demo 8: PDFWorker.create and the Real/Fake Worker HandshakeOctober 27, 2026
- PDF.js Deep Dive from Demo 7: WorkerTransport InitializationOctober 27, 2026
- PDF.js Deep Dive from Demo 6: MessageHandler Cross-Thread CommunicationOctober 27, 2026
- PDF.js Deep Dive from Demo 5: renderOctober 27, 2026
- PDF.js Deep Dive from Demo 4: getViewportOctober 27, 2026
- PDF.js Deep Dive from Demo 3: getPageOctober 27, 2026
- PDF.js Deep Dive from Demo 2: getDocumentOctober 27, 2026
- PDF.js Source Walkthrough: TextLayerOctober 27, 2026
- PDF.js Source Walkthrough: font_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_displayOctober 27, 2026
- PDF.js Source Walkthrough: PDFObjectsOctober 27, 2026
- PDF.js Source Walkthrough: Network StreamsOctober 27, 2026
- PDF.js Source Walkthrough: internal_evt.jsOctober 27, 2026
- PDF.js Source Walkthrough: math_clamp.jsOctober 27, 2026
- PDF.js Source Walkthrough: css_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: scripting_utilsOctober 27, 2026
- PDF.js Source Walkthrough: MurmurHash3_64October 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_utilsOctober 27, 2026
- PDF.js Source Walkthrough: BasePDFStreamOctober 27, 2026
- PDF.js Source Walkthrough: Shared UtilitiesOctober 27, 2026
- PDF.js Source Walkthrough: MessageHandlerOctober 27, 2026
- PDF.js Source Walkthrough: apiOctober 27, 2026
- PDF.js Source Walkthrough: fontsOctober 27, 2026
- PDF.js Source Walkthrough: glyphlistOctober 27, 2026
- PDF.js Source Walkthrough: binary_cmapOctober 27, 2026
- PDF.js Source Walkthrough: cmapOctober 27, 2026
- PDF.js Source Walkthrough: encodingsOctober 27, 2026
- PDF.js Source Walkthrough: metricsOctober 27, 2026
- PDF.js Source Walkthrough: standard_fontsOctober 27, 2026
- PDF.js Source Walkthrough: fonts_utilsOctober 27, 2026
- PDF.js Source Walkthrough: default_appearanceOctober 27, 2026
- PDF.js Source Walkthrough: metadata_parserOctober 27, 2026
- PDF.js Source Walkthrough: name_number_treeOctober 27, 2026
- PDF.js Source Walkthrough: struct_treeOctober 27, 2026
- PDF.js Source Walkthrough: catalogOctober 27, 2026
- PDF.js Source Walkthrough: pdf_managerOctober 27, 2026
- PDF.js Source Walkthrough: object_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_coreOctober 27, 2026
- PDF.js Source Walkthrough: cryptoOctober 27, 2026
- PDF.js Source Walkthrough: core_utilsOctober 27, 2026
- PDF.js Source Walkthrough: primitivesOctober 27, 2026
- PDF.js Source Walkthrough: worker_streamOctober 27, 2026
- PDF.js Source Walkthrough: ccitt_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpx_streamOctober 27, 2026
- PDF.js Source Walkthrough: jbig2_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpeg_streamOctober 27, 2026
- PDF.js Source Walkthrough: decrypt_streamOctober 27, 2026
- PDF.js Source Walkthrough: brotli_streamOctober 27, 2026
- PDF.js Source Walkthrough: run_length_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_hex_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_85_streamOctober 27, 2026
- PDF.js Source Walkthrough: lzw_streamOctober 27, 2026
- PDF.js Source Walkthrough: predictor_streamOctober 27, 2026
- PDF.js Source Walkthrough: flate_streamOctober 27, 2026
- PDF.js Source Walkthrough: 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 from Demo 1: Rendering a PDF with a Custom ViewerOctober 19, 2026
- PDF.js Deep Dive 2: getDocument & getPageOctober 19, 2026
- PDF.js Deep Dive 1: The Architecture of PDF.jsOctober 16, 2026