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

Introduction
The previous dozen articles all covered filters: Flate, LZW, ASCIIHex, ASCII85, RunLength, Brotli, Predictor. They share one rhythm — implement readBlock, produce a small slice of bytes each time, and let the read loop in DecodeStream stitch the slices into continuous data. The input may arrive in network chunks, and the decoder only cares about the current window.
From this article on we enter the image decoding layer. Image codecs are fundamentally different from general compression: the codestreams of JPEG, JPEG 2000, JBIG2 and CCITT are organised by whole image or whole row, and there are dependencies between blocks, between rows, and between wavelet levels. They cannot be cut anywhere and decoded independently for the first few bytes the way Deflate can. PDF.js therefore unifies the outlet of these streams into a single decodeImage call instead of letting them emit bytes block by block.
The first article in this layer looks at JpxStream in src/core/jpx_stream.js (68 lines). It handles JPEG 2000, which is the /JPXDecode filter in PDF. JPEG 2000 is defined by ISO/IEC 15444 and has been supported since PDF 1.5; it compresses continuous-tone photographs as well as bi-level scans, and it allows an alpha channel to be carried inside the codestream (SMaskInData), which makes it common in medical imaging, maps and high-quality scanned PDFs. CCITT fax decoding belongs to the same layer (covered in the next article); both produce the whole pixel image in one decodeImage, and they differ only in how bytes are fetched and where the parameters come from.
The entry point is Parser.makeFilter:
case "JPX":
case "JPXDecode":
return new JpxStream(stream, maybeLength);Note that only two arguments are passed here, and DecodeParms is not forwarded. The DCT and CCITT constructors both accept params; JPX does not. The reason is expanded in the section "Where Decode Parameters Come From": the decoding options JPEG 2000 actually needs depend on the image dictionary and the render size rather than on the filter dictionary, so PDFImage prepares them one layer up and passes them in through decoderOptions.
In one sentence, its job is: take the complete upstream codestream, hand it to the singleton JpxImage for asynchronous decoding into a pixel buffer, and write that result straight into the output of DecodeStream.
Module Overview
Without the copyright header and imports, jpx_stream.js is only a little over forty lines, and it splits into five blocks:
| Block | Key Members | Responsibility |
|---|---|---|
| Construction | constructor | Store the upstream stream, the dictionary and the length hint |
| Bytes | bytes | Lazily fetch the whole codestream and cache it with shadow |
| Buffer | ensureBuffer | Empty implementation, declaring that this stream does not grow incrementally |
| Decoding | decodeImage | Call JpxImage.instance.decode for asynchronous whole-image decoding |
| Capability declarations | isAsyncDecoder, isImageStream, canAsyncDecodeImageFromBuffer | Tell the base class and callers how to use this stream |
Where it sits in the chain:
XObject dictionary /Filter /JPXDecode
→ Parser.makeFilter
→ JpxStream(stream, maybeLength)
→ PDFImage reads the dictionary, builds decoderOptions
→ DecodeStream.getImageData
→ JpxStream.decodeImage
→ JpxImage.instance.decode (OpenJPEG WASM)
→ pixel buffer
→ PDFImage / ImageResizer / main threadCompared with traditional filters, the biggest difference is that there is no readBlock. The synchronous read contract of DecodeStream is deliberately abandoned here and replaced with the asynchronous contract of decodeImage.
Whole-Image Decoding: Why There Is No readBlock
The DecodeStream base class defines readBlock as an abstract method that subclasses must implement:
readBlock() {
unreachable("Abstract method `readBlock` called");
}Flate and LZW both override it. JpxStream does not, because it cannot satisfy the semantics of "read a chunk, decode a chunk". A JPEG 2000 codestream is made of multiple tiles, multiple resolution levels and arithmetic coding passes; the decoder must have the complete codestream (at least the whole tile-part) before it can determine the pixels of an image. Forcing a chunked split would only make the decoder re-initialize repeatedly, or produce an incomplete image.
So the call path switches to DecodeStream.getImageData:
async getImageData(length, decoderOptions) {
if (!this.canAsyncDecodeImageFromBuffer) {
return this.isAsyncDecoder
? this.decodeImage(null, length, decoderOptions)
: this.getBytes(length, decoderOptions);
}
const data = await this.stream.asyncGetBytes();
return this.decodeImage(data, length, decoderOptions);
}This base-class snippet splits image streams into three cases:
| Condition | Path | Applies to |
|---|---|---|
| Cannot decode from a buffer asynchronously, and is an async decoder | decodeImage(null, length, options) | CCITTFaxStream; Jbig2Stream on a synchronous upstream |
| Cannot decode from a buffer asynchronously, and is not an async decoder | getBytes(length, options) | An ordinary DecodeStream |
| Can decode from a buffer asynchronously | await asyncGetBytes first, then decode | JpxStream, JpegStream; Jbig2Stream on an async upstream |
The two capability declarations of JpxStream decide that it takes the third row:
get isAsyncDecoder() {
return true;
}
get canAsyncDecodeImageFromBuffer() {
return this.stream.isAsync;
}isAsyncDecoder means "this decoder is itself asynchronous"; canAsyncDecodeImageFromBuffer means "the whole buffer can be fetched asynchronously from the underlying stream first". They are not the same thing: the former describes the decoder, the latter describes the data source. JpxStream delegates the latter to this.stream.isAsync, so:
- When the upstream is a
ChunkedStream(a network PDF),isAsyncistrue, the base class firstawaitsthis.stream.asyncGetBytes()to download the whole image, then callsdecodeImage(data, ...). - When the upstream is an ordinary
Stream(memory or a local file),isAsyncisfalse, the base class callsdecodeImage(null, ...), andbytes ||= this.bytesinsidedecodeImagefetches the codestream synchronously.
Design note:
canAsyncDecodeImageFromBuffermakes the byte-fetching strategy follow the data source instead of being hard-coded in the decoder. The sameJpxStreamprefetches asynchronously on a network stream and reads synchronously on a memory stream, without changing a single line of decoding logic.
Construction and bytes: Lazily Caching the Whole Codestream
The constructor Method
class JpxStream extends DecodeStream {
constructor(stream, maybeLength) {
super(maybeLength);
this.stream = stream;
this.dict = stream.dict;
this.maybeLength = maybeLength;
}The constructor does only three things:
| Action | Purpose |
|---|---|
super(maybeLength) | Let the base class turn the length hint into a power of two not smaller than it, as the initial capacity |
this.stream = stream | Keep the upstream stream; both bytes and asynchronous prefetching need it |
this.dict = stream.dict | Pass through the image dictionary; PDFImage later reads Width, Height and SMaskInData from it |
this.dict = stream.dict is used instead of taking a separate constructor parameter because a filter chain always shares one dictionary: no matter how many layers of Flate or Predictor are stacked in between, the outermost /Width and /Height still describe the final image. In PDF.js, JPX is usually the last layer of the filter chain, but it follows the same convention.
maybeLength is stored separately. The base class only keeps it as _rawMinBufferLength for buffer growth, while bytes needs the original value to decide how many bytes to request from the upstream; null and a concrete number mean different things, and the value cannot be reconstructed from the base class's power-of-two capacity.
The bytes Method
get bytes() {
// If `this.maybeLength` is null, we'll get the entire stream.
return shadow(this, "bytes", this.stream.getBytes(this.maybeLength));
}What shadow does is write the getter's first result directly onto the instance as a property, so later accesses skip the function body. For a complete image that only needs to be read once, this avoids both repeated byte fetches and repeated allocations.
The value of maybeLength determines the read range:
maybeLength | Behaviour of getBytes | Result |
|---|---|---|
| A concrete number | Read the given length | Usually equal to /Length |
null | Read all the way to eof | The whole upstream stream |
PDF's /Length is sometimes unreliable, especially in incrementally updated or damaged files; falling back to "read everything" when maybeLength is null is the safer option. Note that no length validation happens here: whatever is fetched gets decoded, and the real validity checks live inside JpxImage.
The ensureBuffer Method
ensureBuffer(requested) {
// No-op, since `this.readBlock` will always parse the entire image and
// directly insert all of its data into `this.buffer`.
}This is an empty implementation; the requested parameter is completely unused. Its presence is a contract declaration: JpxStream will not grow its buffer through the base class's geometric expansion, because decodeImage assigns the decoding result to this.buffer in one shot.
From the perspective of DecodeStream.getBytes, this empty implementation actually means "do not take the synchronous path": getBytes first calls ensureBuffer and then calls readBlock in a loop; JpxStream has no readBlock, so the synchronous path is bound to hit the base class's unreachable. The class can therefore only be consumed through getImageData, and the empty ensureBuffer cooperates with this narrowed contract.
Design note: An empty implementation is not code saved; it is an explicit denial of one base-class capability.
DecodeStreammakes "buffer growth plus chunked reading" the default template, and image streams use an emptyensureBufferand a missingreadBlockto tell the reader: the template does not apply here, use the image-specific entry point.
decodeImage: Asynchronous Whole-Image Decoding
The decodeImage Method
async decodeImage(bytes, _length, decoderOptions) {
if (this.eof) {
return this.buffer;
}
bytes ||= this.bytes;
this.buffer = await JpxImage.instance.decode(bytes, decoderOptions);
this.bufferLength = this.buffer.length;
this.eof = true;
return this.buffer;
}Line by line:
| Line | Meaning |
|---|---|
if (this.eof) return this.buffer | Decoding is idempotent; a repeated call returns the previous result |
bytes ||= this.bytes | When the base class did not prefetch, fetch the whole codestream synchronously here |
await JpxImage.instance.decode(bytes, decoderOptions) | Hand over to the singleton decoder and wait asynchronously for WASM |
this.bufferLength = this.buffer.length | The output length follows the actual pixels, not _length |
this.eof = true | Mark the whole image as done; later reads return immediately |
The third parameter decoderOptions is forwarded as-is, while _length is ignored. This trade-off is essential: the output size of JPEG 2000 is determined by the SIZ marker segment in the codestream header; /Width and /Height in the PDF dictionary are only used for layout and validation. Pre-allocating or truncating according to _length would actually corrupt the image.
bytes ||= this.bytes uses logical-or assignment, so it only falls back when bytes is null or undefined. An empty Uint8Array is truthy and is not replaced, so a zero-length codestream is passed to the decoder as-is and produces an explicit decoding error instead of being silently swapped for upstream data.
JpxImage.instance
The entry point of JpxImage is a singleton:
class JpxImage extends WasmImage {
_filename = "openjpeg.wasm";
static get instance() {
return shadow(this, "instance", new JpxImage(/* trackInstance = */ true));
}instance is cached through shadow, so the whole Worker shares one JpxImage. That means the OpenJPEG WASM module is initialized only once, at the cost of writing the codestream into the module heap and reading the result back out on every decode. The actual method body does three things:
const size = bytes.length;
ptr = module._malloc(size);
module.writeArrayToMemory(bytes, ptr);
const ret = module._jp2_decode(
ptr,
size,
numComponents > 0 ? numComponents : 0,
!!isIndexedColormap,
!!smaskInData,
reducePower,
);_malloc reserves heap space the size of the codestream, writeArrayToMemory copies the JS Uint8Array into it, and _jp2_decode performs entropy decoding, dequantization, the inverse wavelet transform and color transformation inside WASM. The finally block always calls _free, so memory does not leak even when decoding throws.
The returned imageData is detached from the module (module.imageData = null), so the next decode cannot read a stale result.
Design note: The singleton reuses the module, not the state. Every call still allocates, copies and releases its own codestream, and the output is detached from the module. That saves repeated initialization without letting two decodes contaminate each other.
Where Decode Parameters Come From
JpxStream never parses /DecodeParms itself, and decoderOptions do not come from the filter chain. The real construction happens in PDFImage:
this.jpxDecoderOptions = {
numComponents: 0,
isIndexedColormap: false,
smaskInData: dict.get('SMaskInData') >= 1,
reducePower,
};Those four fields are not final after the first pass, though. Once the image color space has been resolved, PDFImage fills in two more fields:
if (this.jpxDecoderOptions) {
this.jpxDecoderOptions.numComponents = hasColorSpace ? this.numComps : 0;
// If the jpx image has a color space then it mustn't be used in order
// to be able to use the color space that comes from the pdf.
this.jpxDecoderOptions.isIndexedColormap = this.colorSpace.name === 'Indexed';
}So the final source of each field is:
| Field | Source | Role |
|---|---|---|
numComponents | colorSpace.numComps when a color space exists, otherwise 0 | 0 means let the decoder decide the output component count from the codestream |
isIndexedColormap | colorSpace.name === "Indexed" | Indexed color must be expanded through the palette by the decoder |
smaskInData | Image dictionary /SMaskInData greater than or equal to 1 | The codestream carries its own alpha and must be decoded to RGBA |
reducePower | ImageResizer.getReducePowerForJPX | Downsampling level, which directly reduces the output pixel count |
Specifying numComponents when a color space exists aligns the component count in the codestream with the color space declared by the PDF. JPEG 2000 allows a codestream to carry its own color space, but the PDF specification requires the dictionary's /ColorSpace to win; passing the component count explicitly is the same as telling the decoder "produce output according to the PDF convention", and it prevents ICC information embedded in the codestream from overriding the page resources. isIndexedColormap follows the same idea: indexed color has to be expanded to RGB by the decoder, otherwise the index values handed upward cannot be rendered directly.
smaskInData also affects the choice of color space. When SMaskInData is greater than or equal to 1, PDFImage forces the color space to DeviceRGBA, and when the value is 2 it additionally sets jpxPremultiplied (alpha already premultiplied). That means the same codestream may decode to RGB, RGBA or premultiplied RGBA, and decodeImage needs to know nothing about the differences — it only passes decoderOptions through.
reducePower is one of the most valuable features of JPEG 2000: the inverse wavelet can reconstruct only a lower resolution level instead of decoding at full size and then scaling down. For a 6000 by 8000 scan, dropping one level leaves a quarter of the pixels, and dropping two leaves one sixteenth. The decoder lowers the output resolution by that exponent inside _jp2_decode, and PDFImage shrinks image.width and image.height by 2 ** reducePower rounded up, so later layout stays consistent with the pixel dimensions.
This also explains why Parser.makeFilter does not pass params to JpxStream: the filter dictionary only knows which algorithm is used, not how large the final drawing is. Decoding options depend on the render size, the color space and the alpha requirement, and only PDFImage has all of that information.
isImageStream: A Clear Signal to Callers
JpxStream's last capability declaration is:
get isImageStream() {
return true;
}This boolean takes no part in decoding, yet it affects two upper-level branches. The base class returns false by default, and only image streams override it to true:
| Usage site | Behaviour |
|---|---|
StreamsSequenceStream construction | Filter out streams whose isImageStream is true, so images are not concatenated as content streams |
document.getContentStream | Wrap the content into a new Stream only when the content stream is not an image stream |
The first one matters most. The content of a page may consist of several streams, and StreamsSequenceStream joins them end to end and parses them as a single content stream. If an image XObject stream were mixed in, the Lexer would read pixel bytes as PDF operators, and the result would inevitably be garbage and exceptions. isImageStream stops this misuse at the first layer:
streams = streams.filter(s => s instanceof BaseStream && !s.isImageStream);The second appears where page content is assembled:
if (content instanceof BaseStream && !content.isImageStream) {
if (content.isAsync) {
const bytes = await content.asyncGetBytes();
if (bytes) {
return new Stream(bytes, 0, bytes.length, content.dict);
}
}
return content;
}Image streams are excluded because they can only be consumed through getImageData, not fetched as bytes and re-wrapped into a Stream the way content streams can. Without this check, JpxStream would be treated as an ordinary content stream, and the subsequent synchronous read would run into the missing readBlock.
Design note: A boolean getter carries a type-marker responsibility, not a behaviour switch. The decoding path is separated by
decodeImage, the content-stream path is separated byisImageStream, and the two do not interfere. PDF.js deliberately avoids putting image streams and content streams into one type check.
Comparison with the JPEG Decoder Choice
The mainstream image encodings in PDF take different routes inside PDF.js:
| Dimension | DCTDecode (JPEG) | JPXDecode (JPEG 2000) | CCITTFaxDecode / JBIG2Decode |
|---|---|---|---|
| Implementation | jpeg_stream.js + jpg.js | jpx_stream.js + OpenJPEG WASM | ccitt_stream.js / jbig2_stream.js |
| Pure JS decoding | Yes, built into JpegImage | No, must go through WASM | No, must go through WASM |
| Browser-native fast path | Yes, ImageDecoder can produce an ImageBitmap | No | No |
| Decoding synchronicity | decodeImage is synchronous, callable from readBlock | decodeImage is asynchronous | decodeImage is asynchronous |
Implements readBlock | Yes, it calls decodeImage internally | No | No |
| Parameter source | /ColorTransform | PDFImage builds decoderOptions | Seven keys of /DecodeParms |
| Main optimization | Native decoder, downsampling, skipping useless bytes | Wavelet downsampling via reducePower, in-codestream alpha | Bit-level fax decoding, direct monochrome bitmap output |
Three conclusions:
- A synchronous decoder can keep
readBlock; an asynchronous one cannot.JpegStream.decodeImageis a synchronous function, soreadBlockcan simply delegate to it and the oldgetBytespath still works; JPX decoding has to wait for WASM, cannot fit into a synchronousreadBlock, and is therefore narrowed togetImageData. - The native fast path is open to JPEG only.
JpegStream.getTransferableImagetriesImageDecoderand gets a browser-decodedImageBitmapdirectly, skipping pixel decoding inside the Worker;JpxStreamhas no such method, because browsers generally do not hand JPEG 2000 toImageDecoder. - The location of a parameter is decided by the location of its information. JPEG needs only a
ColorTransformhint, soDecodeParmsis enough; JPX needs the component count, alpha and a downsampling level, which can only be computed once the image dictionary and the render size are known, so the parameters flow fromPDFImageintodecodeImage.
Design note: Both are "whole-image decoding", yet
JpegStreamandJpxStreamhave different capability boundaries. The criterion is not the encoding format but whether the decoder is synchronous, whether the browser offers a native fast path, and whether the parameters can be determined at the filter layer. The three capability declarations ofJpxStreamare exactly the result of those three points.
Boundaries, Errors and Idempotence
JpxStream is short, so its edge cases are concentrated in a few spots:
| Situation | Behaviour |
|---|---|
Second call to decodeImage | eof is true, buffer is returned directly, no repeated decoding |
| Base class already prefetched bytes asynchronously | bytes is non-empty, this.bytes is not touched |
| Base class did not prefetch | bytes ||= this.bytes fetches the whole stream synchronously |
| Corrupt codestream | WASM returns an error code, JpxImage throws JpxError, eof stays false |
| WASM initialization failure | Throws JpxError("OpenJPEG failed to initialize") |
Output pixels larger than /Length | Normal; bufferLength records the real pixel length |
decoderOptions not provided | The destructuring defaults of JpxImage.decode apply, all four fields take defaults |
Two details deserve a separate mention.
First, eof is set only after a successful decode. If JpxImage.decode throws, buffer is not assigned and eof is still false, so the next call tries again. For network streams this provides a retry opportunity; for corrupt files the error reproduces reliably and is not masked by the previous failure state.
Second, ignoring _length is not an oversight. When PDFImage computes length it uses an estimate of width times height times components, whereas the actual JPX output may be RGBA, may be downsampled, or may even carry an extra channel because of SMaskInData. Only the codestream itself can guarantee that bufferLength corresponds one to one with the real pixels.
Design note: Error handling does one thing only — it does not set
eof— and leaves the retry policy to the caller.JpxStreamneither swallows exceptions nor returns an empty buffer, because "image decoding failed" and "the image is blank" are entirely different matters at the rendering layer.
Conclusion
JpxStream is the JPEG 2000 whole-image decoding adapter of PDF.js: it takes the complete upstream codestream, hands it together with the decoding options prepared above to OpenJPEG WASM, and writes the pixel buffer as the output of DecodeStream. It does not implement readBlock, the decoding path does not rely on incremental buffer growth, and all of its capabilities revolve around a single asynchronous decode.
Where It Sits in the Chain
| Stage | Module | Input | Output |
|---|---|---|---|
| Filter selection | Parser.makeFilter | JPXDecode in /Filter | JpxStream |
| Property probing | JpxImage.parseImageProperties | Raw codestream | Width, height, component count, bit depth |
| Parameter generation | PDFImage | Image dictionary, target size | jpxDecoderOptions |
| Byte fetching | JpxStream.bytes / asyncGetBytes | Upstream stream, maybeLength | Complete codestream |
| Decoding | JpxStream.decodeImage | Codestream, decoderOptions | Pixel buffer |
| Consumption | PDFImage, ImageResizer | Pixel buffer | ImageData or a bitmap |
In one sentence: Parser creates the stream, PDFImage prepares the parameters, DecodeStream.getImageData decides how bytes are fetched, decodeImage waits for WASM to finish the whole image, and the rendering layer only receives pixels.
Five Design Principles
- Image streams do not implement
readBlock. A whole-image encoding cannot be decoded independently block by block, so the synchronous read contract fails here; the capability is narrowed togetImageData, anddecodeImagedoes all the work. - Bytes are lazy and fetched once.
bytesis cached withshadow, and falls back to reading everything whenmaybeLengthisnull; on network streams,canAsyncDecodeImageFromBufferprefetches asynchronously ahead of time. - Parameters are layered. The filter layer only creates the stream; decoding options are produced by
PDFImage, which holds the image dictionary and the render size, and are forwarded asdecoderOptions, so lower layers never have to guess the intentions of upper layers. - A singleton is reused; each decode is isolated.
JpxImage.instanceshares the WASM module, while every decode allocates and releases its own codestream memory and detaches the output promptly, avoiding contamination between calls. - The codestream is the authority on output.
_lengthis ignored andbufferLengthis filled with the real pixel length, so both downsampling and in-codestream alpha are reflected correctly downstream.
The next article looks at ccitt_stream.js in the same layer. It is also a whole-image decoder, but it moves the asynchronous byte fetching inside decodeImage, and it flattens the seven fax-encoding parameters from the dictionary into one plain object — two routes that form the two answers to "who decides where the bytes come from" in the image decoding layer.
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: 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: 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 2: getDocument & getPageOctober 19, 2026
- PDF.js Deep Dive 1: The Architecture of PDF.jsOctober 16, 2026