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

Introduction
This is the article in the PDF.js source walkthrough series about XMP metadata. It covers src/core/metadata_parser.js (146 lines).
Besides the title, author and creation date in the /Info dictionary, a PDF document can carry a Metadata stream. The content of that stream is XMP, which is essentially RDF/XML. Viewers use it to read richer information: dc:title, dc:creator, xmp:CreateDate, pdf:Producer, and vendor-specific fields.
XMP in real files is often not well formed: there may be junk bytes before the stream, Ghostscript writes UTF-16BE text as octal escapes, namespace prefixes vary in case, and arrays are mixed with language alternatives. The job of metadata_parser.js is not to be a general XML parser but to repair this possibly damaged XMP as far as possible and flatten it into a table from field name to a string or an array of strings. It preserves no DOM, does no XPath, and keeps no namespace objects.
Module Overview
| Block | Key members | Responsibility |
|---|---|---|
| Entry point | MetadataParser constructor | Repair the text first, then hand it to SimpleXMLParser to build a tree |
| Text repair | _repair | Strip leading junk, restore octal escapes, escape invalid characters |
| Tree traversal | _parse, _parseArray, _getSequence | Find the RDF root and description nodes, collect fields |
| Output | serializable | Return the parsed Map and the repaired text |
Where it sits in the chain:
Catalog.getMetadata
→ XRef.fetch(Metadata stream)
→ BaseStream.getString
→ stringToUTF8String
→ new MetadataParser(data)
→ _repair
→ SimpleXMLParser.parseFromString
→ _parse
→ serializable
→ Metadata (display layer)
→ app.js / pdf_document_properties.jsVersion note: This article follows exactly the version of
metadata_parser.jsin the current repository.MetadataParserdoes not extendSimpleXMLParser; it composes one: the constructor creates a parser and handsdatato it. There are nobeginElement/endElementmethods in the source; the corresponding parser callbacks areonBeginElement/onEndElement. There are also noonclosetag/onopentagcallbacks, because text and CDATA go throughonTextandonCdatarespectively. Likewise there is noparse()method returningxmpandxdcdictionaries; this version has onlyserializable, which returns aMapand a string. The_metadata,xmpandxdcfields common in older versions do not exist here.
Entry Point: The Constructor and _repair
The constructor is short, but the order matters: repair first, then parse.
class MetadataParser {
constructor(data) {
// Ghostscript may produce invalid metadata, so try to repair that first.
data = this._repair(data);
// Convert the string to an XML document.
const parser = new SimpleXMLParser({ lowerCaseName: true });
const xmlDocument = parser.parseFromString(data);
this._metadataMap = new Map();
this._data = data;
if (xmlDocument) {
this._parse(xmlDocument);
}
}The four actions are: repair the text, create a parser that lowercases element names, build a lightweight DOM, and initialize an empty map and traverse the DOM if it exists. Two points are easy to miss.
First, this._data holds the repaired text, not the original text passed by the caller. serializable.rawData is therefore the output of _repair; if repair changed any characters, it no longer matches the bytes in the stream character for character.
Second, lowerCaseName: true applies to all element names. The parser lowercases names in the start-tag callback, so the source compares rdf:rdf, rdf:description, rdf:bag and rdf:li, and field names are normalized to dc:title, xmp:createdate and pdf:producer. Consumers must use lowercase keys.
The _repair Method
_repair is a chain of regular-expression replacements that handles only two known kinds of bad data.
_repair(data) {
// Start by removing any "junk" before the first tag (see issue 10395).
return data
.replace(/^[^<]+/, "")
.replaceAll(/>\\376\\377([^<]+)/g, function (all, codes) {
const bytes = codes
.replaceAll(/\\([0-3])([0-7])([0-7])/g, function (code, d1, d2, d3) {
return String.fromCharCode(d1 * 64 + d2 * 8 + d3 * 1);
})
.replaceAll(/&(amp|apos|gt|lt|quot);/g, function (str, name) {
switch (name) {
case "amp":
return "&";
case "apos":
return "'";
case "gt":
return ">";
case "lt":
return "<";
case "quot":
return '"';
}
throw new Error(`_repair: ${name} isn't defined.`);
});
const charBuf = [">"];
for (let i = 0, ii = bytes.length; i < ii; i += 2) {
const code = bytes.charCodeAt(i) * 256 + bytes.charCodeAt(i + 1);
if (
code >= /* Space = */ 32 &&
code < /* Delete = */ 127 &&
code !== /* '<' = */ 60 &&
code !== /* '>' = */ 62 &&
code !== /* '&' = */ 38
) {
charBuf.push(String.fromCharCode(code));
} else {
charBuf.push(
"&#x" + (0x10000 + code).toString(16).substring(1) + ";"
);
}
}
return charBuf.join("");
});
}Broken into six steps in execution order:
| Step | Code | Effect |
|---|---|---|
| 1 | replace(/^[^<]+/, "") | Delete every character before the first tag |
| 2 | replaceAll(/>\\376\\377([^<]+)/g, ...) | Find tag end, octal BOM and text |
| 3 | replaceAll(/\\([0-3])([0-7])([0-7])/g, ...) | Restore three-digit octal escapes to single-byte characters |
| 4 | replaceAll(/&(amp|apos|gt|lt|quot);/g, ...) | Decode the five predefined entities |
| 5 | Combine every two characters into a 16-bit code unit | Interpret the byte stream as UTF-16BE |
| 6 | Keep printable ASCII, turn the rest into numeric character references | Keep control and markup characters from breaking the later parse |
Step 1 corresponds to issue 10395. The regex matches from the start to the first < and removes the entire description in front of it. If the data contains no <, the result is an empty string and the later parse naturally produces an empty table.
Step 2 targets Ghostscript. It writes UTF-16BE text with octal escapes such as \376\377: \376 is 0xFE and \377 is 0xFF, and together they form the BOM. The regex captures the >, the two escapes, and everything after them up to the next <. The > is placed alone as the first element of charBuf and preserved as is.
Step 3 restores each octal escape to a one-byte character. d1, d2 and d3 are strings, and multiplication converts them to numbers: the first digit times 64, the second times 8, the third times 1. The first digit ranges from 0 to 3 and the last two from 0 to 7, so the maximum is 0o377, exactly one byte.
Step 4 decodes entities. Restoring octal before decoding entities means a literal & becomes & and is not later mistaken for markup. Only the five predefined entities are handled; any other name throws.
Step 5 interprets two characters at a time. charCodeAt(i) * 256 + charCodeAt(i + 1) combines high and low bytes into one 16-bit code unit. It assumes an even length; for an odd length the last pair reads NaN and produces an invalid entity, and the source has no guard for that extreme case.
Step 6 decides how each code unit is written back to XML:
| Code unit range | Output form | Reason |
|---|---|---|
32 to 126, and not 60, 62 or 38 | The original character | Printable ASCII; writing it directly is most compact |
| Anything else | &#xNNNN; numeric character reference | Control characters, non-ASCII, markup characters |
60, 62 and 38 are <, > and &. Written raw, XML parsers would read them as the start of a tag, the end of a tag, or the start of an entity, so they must be escaped.
The numeric entity construction is worth a closer look:
'&#x' + (0x10000 + code).toString(16).substring(1) + ';';code is at most 0xFFFF; adding 0x10000 and converting to hexadecimal always yields five digits whose first digit is 1; substring(1) strips the prefix and leaves exactly four. This is a zero-padding trick that does not rely on padStart.
Design note:
_repairfixes only the two known kinds of damage and does not try to understand XML structure. Writing every non-ASCII code unit as a numeric character reference turns the encoding problem into plain ASCII text, so the later parser need not care whether the original bytes were UTF-8, UTF-16BE or PDFDocEncoding. The repair boundary is narrow, but it covers the two most common failures in real files.
How SimpleXMLParser Cooperates
MetadataParser uses SimpleXMLParser from src/core/xml_parser.js. It does not implement the browser DOM; it only builds element nodes, text nodes and parent-child relationships. The parser's state is kept in two arrays: _currentFragment points at the array into which new nodes should currently go, and _stack holds the outer arrays. A start tag pushes and switches, an end tag pops and switches back, and the whole tree is therefore built out of nested arrays.
The two callbacks that really determine node shape are:
onBeginElement(name, attributes, isEmpty) {
if (this._lowerCaseName) {
name = name.toLowerCase();
}
const node = new SimpleDOMNode(name);
node.childNodes = [];
if (this._hasAttributes) {
node.attributes = attributes;
}
this._currentFragment.push(node);
if (isEmpty) {
return;
}
this._stack.push(this._currentFragment);
this._currentFragment = node.childNodes;
}
onEndElement(name) {
this._currentFragment = this._stack.pop() || [];
const lastElement = this._currentFragment.at(-1);
if (!lastElement) {
return null;
}
for (const childNode of lastElement.childNodes) {
childNode.parentNode = lastElement;
}
return lastElement;
}A few characteristics are worth listing:
| Characteristic | Implementation | Consequence |
|---|---|---|
| Name casing | Lowercased when lowerCaseName is true | Later comparisons only need lowercase forms |
| Self-closing tags | When isEmpty is true, only create the node, no push | Empty elements such as rdf:li leave no extra level |
| End tags do not check names | onEndElement pops the top of the stack without comparing name | Malformed nesting does not error; it closes the most recent unclosed element |
| Parent pointers | A child's parentNode is set on its end tag | Text nodes get a parent too |
| Multiple root elements | parseFromString keeps only the first documentElement | Later roots are ignored |
hasAttributes is not passed at construction, so elements have no attributes field. Attributes such as xml:lang are dropped during parsing, which means language information cannot be preserved for language alternatives in dc:title.
Text nodes are created by onText and onCdata. onText first checks isWhitespaceString and returns immediately for pure whitespace; otherwise it creates a #text node and appends it to the current array. This matters for traversal: rdf:Description's child list contains no runs of whitespace #text, so the source only has to skip scattered non-whitespace text. onCdata has the same structure but does not filter whitespace, because whitespace inside CDATA may be meaningful.
SimpleDOMNode.textContent concatenates recursively:
get textContent() {
return !this.childNodes
? this.nodeValue || ""
: this.childNodes.map(child => child.textContent).join("");
}With no children it returns nodeValue; with children it concatenates the text of all of them. It inserts no separator, so a field with several nested rdf:li elements becomes one continuous string rather than an array. Array semantics must be handled separately by _parseArray.
parseFromString sets an error code and returns undefined on any syntax error; it also returns undefined when there is no root node. When MetadataParser sees undefined it does not call _parse, and the result is an empty table rather than an exception.
Design note:
SimpleXMLParseris a good-enough parser. It does not validate tag pairing, expand custom entities, keep attributes or handle namespace declarations. It only guarantees that well-formed common XMP becomes a traversable tree; broken parts are either skipped or cause the whole call to returnundefined. The worker therefore does not have to pull in a full XML library.
Locating rdf:RDF and rdf:Description
After obtaining xmlDocument, _parse first finds the RDF root and then iterates over each description node.
_parse(xmlDocument) {
let rdf = xmlDocument.documentElement;
if (rdf.nodeName !== "rdf:rdf") {
// Wrapped in <xmpmeta>
rdf = rdf.firstChild;
while (rdf && rdf.nodeName !== "rdf:rdf") {
rdf = rdf.nextSibling;
}
}
if (!rdf || rdf.nodeName !== "rdf:rdf" || !rdf.hasChildNodes()) {
return;
}
for (const desc of rdf.childNodes) {
if (desc.nodeName !== "rdf:description") {
continue;
}
for (const entry of desc.childNodes) {
const name = entry.nodeName;
switch (name) {
case "#text":
continue;
case "dc:creator":
case "dc:subject":
this._parseArray(entry);
continue;
}
this._metadataMap.set(name, entry.textContent.trim());
}
}
}Standard XMP has two common outer shapes:
| Outer shape | What documentElement is | Path to the RDF root |
|---|---|---|
Rooted directly at rdf:RDF | rdf:rdf | Use it directly |
Wrapped in x:xmpmeta | x:xmpmeta | Start at the first child and walk siblings |
The second row is the source of the while loop in _parse. It scans only sibling nodes and does not recurse into subtrees. If there is another wrapper layer between xmpmeta and rdf:rdf, this code will not find the target and ultimately returns an empty table. Real files usually have only one wrapper.
Once found, there are three more checks: rdf exists, its name is rdf:rdf, and it has at least one child. If any check fails, the method returns immediately without attempting repairs.
Among the children of rdf:rdf, only rdf:description is processed; every other node is skipped. Inside a description node, each child is processed in turn. #text is explicitly skipped: onText only filtered pure whitespace above, so if a description node contains non-whitespace bare text it becomes a #text node, and the source does not want to treat that as a field name.
The switch special-cases only two names:
| Field name | Handling | Result |
|---|---|---|
dc:creator | _parseArray | Array of strings |
dc:subject | _parseArray | Array of strings |
| Anything else | textContent.trim() | A single string |
Every other field goes through the same single write. This means a dc:title written as an rdf:Alt multilingual array is concatenated into one string rather than an array; the same is true for dc:description; single-value fields such as xmp:CreateDate and pdf:Producer become plain text; nested structures are concatenated recursively and the hierarchy is lost.
This is special-casing by field name, not inferring by value type. The source does not look at rdf:Bag, rdf:Seq or rdf:Alt to decide whether to parse an array; it looks only at the field name. The benefit is an extremely short implementation; the cost is that only the two most common multi-value fields, dc:creator and dc:subject, come back as arrays.
_getSequence and _parseArray
Array fields are produced by two methods working together:
_getSequence(entry) {
const name = entry.nodeName;
if (name !== "rdf:bag" && name !== "rdf:seq" && name !== "rdf:alt") {
return null;
}
return entry.childNodes.filter(node => node.nodeName === "rdf:li");
}
_parseArray(entry) {
if (!entry.hasChildNodes()) {
return;
}
// Child must be a Bag (unordered array) or a Seq.
const [seqNode] = entry.childNodes;
const sequence = this._getSequence(seqNode) || [];
this._metadataMap.set(
entry.nodeName,
sequence.map(node => node.textContent.trim())
);
}_getSequence accepts three container names:
| Container | Semantics | What PDF.js does |
|---|---|---|
rdf:Bag | Unordered collection | Collect every rdf:li |
rdf:Seq | Ordered sequence | Collect every rdf:li |
rdf:Alt | Language alternatives | Collect every rdf:li |
All three collect rdf:li in document order and ignore the unordered versus ordered distinction. The language attributes on Alt were already lost during parsing, so all that remains is an array of the text in each language.
_parseArray takes only the first child of entry as the container. const [seqNode] = entry.childNodes is destructuring, and every later child is ignored. Since whitespace text has been filtered, the first child is normally one of the three containers. If it is not, _getSequence returns null, || [] turns that into an empty array, and an empty array is written instead of skipping the field.
If entry has no children at all, the method returns immediately and the field does not appear in the Map. An empty array and a missing field are therefore two different outcomes, and consumers must distinguish them.
Values are handled uniformly with node.textContent.trim(). For rdf:li this is usually the language text; if there are nested tags inside, they are still concatenated recursively.
Design note:
_parseArraychecks container type and entry type separately. The container must be one of the three RDF collection types, and the entries must berdf:li. But the source only checks the first child and does not handle several containers side by side. This is a minimal implementation aimed at common XMP, not a complete RDF parser.
External Interface: serializable and Consumers
The parse result is exposed through an accessor:
get serializable() {
return {
parsedData: this._metadataMap,
rawData: this._data,
};
}The two fields have different purposes:
| Field | Type | Content | Purpose |
|---|---|---|---|
parsedData | Map | Lowercase field names to strings or string arrays | Structured queries |
rawData | String | The full XML text after _repair | Display, debugging, secondary parsing |
The worker does not hand this object directly to the main thread; it goes through Catalog.getMetadata:
const data = stringToUTF8String(stream.getString());
if (data) {
metadata = new MetadataParser(data).serializable;
}The Metadata wrapper on the main thread does three things: get(name) looks up the table, getRaw() returns the text, and the iterator exposes every key-value pair.
get(name) {
return this.#map.get(name) ?? null;
}
getRaw() {
return this.#data;
}Key names were already lowercased in SimpleXMLParser, so consumers must use lowercase:
| Consumer | Key queried | Notes |
|---|---|---|
web/app.js title | dc:title | A single-value string |
web/app.js author | dc:creator | An array that needs join |
web/app.js producer | pdf:producer | A single-value string |
pdf_document_properties.js | xmp:createdate | Lowercase key, not CreateDate |
pdf_document_properties.js | dc:creator | An array joined with line breaks for display |
This also explains why lowerCaseName cannot simply be turned off: consumers are already hard-coded to lowercase keys. If the parser preserved original case, xmp:CreateDate and xmp:createdate would be two different keys.
Design note:
serializablereturns the structured result together with the raw text. Upper layers can use theMapfor fast lookups and can also get the complete XMP when they need to display it or parse it again. Keys are lowercased during parsing, which pushes case-insensitivity into the worker so the main thread does not normalize on every comparison.
Fault-Tolerance Boundaries
| Situation | Location | Result |
|---|---|---|
| Junk before the first tag | _repair | Removed |
| Ghostscript octal UTF-16BE | _repair | Restored to characters or numeric entities |
| Control characters or non-ASCII text | _repair | Converted to &#xNNNN; |
| XML syntax error | SimpleXMLParser | Returns undefined, producing an empty table |
Root is not rdf:rdf and does not contain it | _parse | Returns immediately, empty table |
rdf:rdf has no children | _parse | Returns immediately, empty table |
| Bare text mixed into a description node | _parse | #text is skipped |
dc:creator has no children | _parseArray | The field is not written |
| Collection container is none of the three | _getSequence | Returns null, an empty array is written |
| Duplicate field | _metadataMap.set | The later value overwrites the earlier one |
| Custom entity | XMLParserBase | Preserved as &name;, not expanded |
The last row is the most notable. SimpleXMLParser's entity handling covers only the five predefined entities and numeric character references; every other entity name goes to onResolveEntity, whose default implementation returns the original string. It does not read a DTD and does not load external entities. Custom entities in XMP therefore cause no network request or file read, and they are not expanded into unexpected content.
Another boundary is multiple rdf:Description nodes. _parse iterates over all direct children and writes the fields from each description into the same Map. If two descriptions write the same field, the later one overwrites the earlier one; the source does not merge, compare or report an error.
Conclusion
metadata_parser.js is PDF.js's metadata flattening layer: downward it receives a possibly damaged XML text, upward it outputs a Map with uniform case and a limited set of value types.
Where It Sits in the Overall Chain
| Stage | Module | Input | Output |
|---|---|---|---|
| Stream read | Catalog.getMetadata | A Metadata stream reference | Bytes |
| Decode | stringToUTF8String | Bytes | A JavaScript string |
| Repair | MetadataParser._repair | The raw string | ASCII-clean XML text |
| Tree build | SimpleXMLParser | XML text | A lightweight DOM |
| Traversal | _parse / _parseArray | The DOM | Map from field name to value |
| Transfer | serializable | The Map and the text | A structured-cloneable object |
| Consumption | Metadata (display layer) | parsedData, rawData | get, getRaw and an iterator |
In one sentence: _repair turns bad text into parseable ASCII, SimpleXMLParser turns the text into a tree, _parse flattens the tree into a table, and Metadata wraps the table in an interface the main thread can query.
Four Design Principles
- Repair before parsing. XMP damage usually happens at the text layer, not the structure layer. Handling leading junk, octal escapes and control characters before they reach the parser means the later state machine needs no branches for them.
- Composition, not inheritance.
MetadataParserneeds an XML parser that can build a tree but does not need to expose all of the parser's callbacks. Composition lets it depend on a single entry point,parseFromString, and avoids mixing base-class state with its own fields. - Special-case by field name, not by value type. Only
dc:creatoranddc:subjecttake the array path; everything else usestextContent. The implementation is very short and covers the most common multi-value fields; consumers handle the rest as needed. - Fail down to an empty table rather than throwing. XML syntax errors, a missing RDF root and missing description nodes all end up as empty metadata. For a viewer, a document without XMP still opens normally, which is more reasonable than aborting the whole load over optional information.
The next article returns to the content stream side. XMP describes what a document is, while a default appearance string describes what annotations and form fields look like. default_appearance.js splits a string such as /Helvetica 0 Tf 0 g into font size and color, and also fabricates a fake font when no usable font data is available.
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: image_utils.jsOctober 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: 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