StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: Shared Utilities

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: Shared Utilities

Introduction

This installment examines src/shared/util.js, a 1,229-line module imported throughout the parser, renderer, annotation system, and editor.

It is tempting to dismiss this file as a miscellaneous utility bag. That misses its real role: it defines the low-level vocabulary and contracts shared by otherwise independent PDF.js subsystems. Numeric PDF flags, operator identifiers, exception shapes, binary-string conversions, browser probes, and geometry routines all need exactly one interpretation. Duplicating them across worker and display code would create subtle incompatibilities.

The module also sits on two different boundaries. Some helpers interpret PDF data, where malformed input and specification-defined numbers are normal concerns. Others adapt to the JavaScript host, where Node.js, browser, worker, and transpiled builds expose different capabilities. Reading the file by functional family makes those boundaries—and the design tradeoffs around them—clear.

Module Overview

The source can be divided into eight implementation blocks plus its export list:

BlockLinesResponsibility
Environment detection and shared constants21-384Host classification, PDF flags, annotation values, operator numbers, and logging levels
Diagnostics386-426Verbosity state, info, warn, unreachable, and assert
URLs, paths, and property definition428-519Protocol allowlisting, hash updates, path stripping, and shadow
Exceptions and byte strings524-616Typed failures plus binary string conversion
FeatureTest618-718Lazy, cached host-capability probes
Util720-1028Colors, affine transforms, scale recovery, rectangles, and Bézier bounds
Text, identity, and destinations1030-1127UTF-8 byte strings, Unicode normalization, UUID generation, and explicit-destination validation
Factories and compatibility patches1129-1164Reusable collection factories and small standard-library shims
Exports1166-1229Expose the shared constants, classes, helpers, and factories

There are two especially useful themes to keep in mind:

  1. A constant can be a compatibility interface. Renumbering OPS would break consumers just as surely as changing a function signature.
  2. Mutation is intentional, not accidental. Several hot-path methods write into caller-provided arrays to avoid allocations; other methods deliberately return copies. The distinction is part of each API.

Constants Are Contracts, Not Just Labels

Rendering intent and annotation modes

RenderingIntentFlag uses separate powers of two, from ANY through OPLIST, so independent facts can travel in one integer: display versus print, annotation handling, editing state, saving, and operator-list production.

AnnotationMode, by contrast, is a set of mutually exclusive numeric options: DISABLE, ENABLE, ENABLE_FORMS, and ENABLE_STORAGE. It is not another bit mask. WorkerTransport.getRenderingIntent in display/api.js translates the selected mode into at most one annotation flag, then adds the editing and operator-list flags when needed:

switch (annotationMode) {
  case AnnotationMode.DISABLE:
    renderingIntent += RenderingIntentFlag.ANNOTATIONS_DISABLE;
    break;
  case AnnotationMode.ENABLE:
    break;
  case AnnotationMode.ENABLE_FORMS:
    renderingIntent += RenderingIntentFlag.ANNOTATIONS_FORMS;
    break;
  case AnnotationMode.ENABLE_STORAGE:
    renderingIntent += RenderingIntentFlag.ANNOTATIONS_STORAGE;
 
    annotationStorageSerializable = annotationStorage.serializable;
    break;
  default:
    warn(`getRenderingIntent - invalid annotationMode: ${annotationMode}`);
}
 
if (isEditing) {
  renderingIntent += RenderingIntentFlag.IS_EDITING;
}
if (isOpList) {
  renderingIntent += RenderingIntentFlag.OPLIST;
}

The source uses +=; it is safe here because each contributing flag is distinct and selected at most once. Consumers then recover facts with bitwise tests. For example, OptionalContentConfig checks DISPLAY and PRINT, while PDFPageProxy.render uses PRINT to select print-specific behavior. The resulting integer also participates in the rendering cache key, so these bits affect both behavior and cache identity.

The other constant families serve different contractual roles:

FamilyContract
PermissionFlag, AnnotationFlag, AnnotationFieldFlagBit masks for document permissions, annotation behavior, and form fields; permission values follow ISO 32000-1, Table 22
AnnotationType, AnnotationBorderStyleType, AnnotationReplyType, AnnotationRenditionOperationNumeric or string vocabularies used when interpreting annotation dictionaries and rendition actions
AnnotationActionEventType, DocumentActionEventType, PageActionEventTypeMaps PDF trigger keys such as E, WC, and O to scripting event names
AnnotationPrefix, AnnotationEditorPrefixNamespaces for internal annotation/editor identifiers and editor storage keys
AnnotationEditorType, AnnotationEditorParamsTypeInternal editor kinds and parameter-update commands; these should not be confused with a single PDF-spec enumeration
TextRenderingModePDF text modes 0 through 7, plus FILL_STROKE_MASK and ADD_TO_PATH_FLAG for decoding the packed modes
ImageKind, MeshFigureTypeImage storage layouts and mesh figure categories shared across parsing and rendering
PasswordResponses, VerbosityLevelPassword request outcomes and diagnostic thresholds

TextRenderingMode is a good example of why these values deserve explanation rather than blind copying. Modes 4 through 7 combine the low fill/stroke behavior with “add to path.” The canvas renderer therefore uses mode & FILL_STROKE_MASK for the basic operation and mode & ADD_TO_PATH_FLAG for clipping-path behavior; a long chain of equality checks would lose that structure.

Stable operation numbers

OPS assigns numeric identifiers to operator-list commands. Two comments in the source define its compatibility rules:

  • numbering starts at 1, making an uninitialized zero easy to recognize;
  • removed entries remain as comments because renumbering would risk breaking third-party users.

The list consequently contains gaps at 78, 79, and 82. Those gaps are intentional. Worker code writes numeric function IDs into an operator list, and display code dispatches on those same IDs in canvas.js; the numbers are part of the data format, not merely switch labels.

DrawOPS has a different requirement. Its five path commands occupy the contiguous range 0 through 4, which the source explicitly keeps suitable for a fast switch jump table. makePathFromDrawOPS in display/display_utils.js consumes that compact representation. Stable numbering matters for OPS; dense numbering matters for DrawOPS.

Shared numeric constants

The remaining constants prevent tiny policy decisions from drifting between modules:

  • FONT_IDENTITY_MATRIX is [0.001, 0, 0, 0.001, 0, 0], the usual 1/1000 glyph-unit scale used as the font-matrix fallback.
  • LINE_FACTOR = 1.35 and LINE_DESCENT_FACTOR = 0.35 encode the single-line field metrics Acrobat appears to use. BASELINE_FACTOR is derived from their ratio rather than duplicated as another literal.
  • BBOX_INIT and F32_BBOX_INIT provide ordinary-array and Float32Array templates for empty accumulated bounds.
  • SVG_NS centralizes the SVG namespace string used by DOM/SVG construction.
  • Util.hexNums lazily builds all 256 two-digit hexadecimal bytes. Util.makeHexColor(r, g, b) indexes that table, so its inputs are expected to be byte values; it does not clamp or validate them.

These are small definitions, but they connect font rendering, annotation appearance, editor line layout, SVG generation, and color serialization to one shared policy.

Diagnostics and the Exception Hierarchy

The module owns one mutable diagnostic variable, initialized to VerbosityLevel.WARNINGS:

let verbosity = VerbosityLevel.WARNINGS;
 
function setVerbosityLevel(level) {
  if (Number.isInteger(level)) {
    verbosity = level;
  }
}
 
function getVerbosityLevel() {
  return verbosity;
}

The setter accepts any integer, not only the three named levels. That is consistent with threshold-based output: warn emits when verbosity >= WARNINGS, while info requires verbosity >= INFOS. warn is explicitly non-fatal. unreachable, in contrast, always throws an Error, and assert routes a failed condition through it. These are programming-failure tools, not replacements for recoverable PDF parse errors.

BaseException supplies the common message/name shape for PDF.js exceptions. Its legacy prototype setup inherits from Error, and direct construction of BaseException itself is rejected in unbuilt or testing code. Derived classes add the information callers need to make decisions:

ExceptionAdditional stateMeaning conveyed by the type
PasswordExceptioncodePassword is required or incorrect; PasswordResponses supplies the codes
UnknownErrorExceptiondetailsAn unrecognized failure plus diagnostic detail
InvalidPDFException—The document is not a valid PDF
ResponseExceptionstatus, missingA network response failed, with status and missing-resource information
FormatError—PDF data could not be parsed as expected
AbortException—A task was cancelled rather than completed unsuccessfully

This taxonomy is used across the worker boundary. message_handler.js reconstructs recognized exceptions from their name, preserving fields such as code, status, missing, and details. Consequently, these constructors are protocol definitions: removing a field would weaken error handling even if local code still compiled.

URL Safety, Path Handling, and Lazy Properties

createValidAbsoluteUrl: validation plus controlled recovery

createValidAbsoluteUrl returns a URL object or null; it does not return an arbitrary “fixed up” string. Its protocol allowlist contains http:, https:, ftp:, mailto:, and tel:. In particular, schemes such as javascript: and data: are rejected by the default branch.

Two recovery options apply only when the input is a string:

  1. addDefaultProtocol prefixes http:// to strings beginning with www., but only when at least two dots are present. This avoids interpreting a relative filename such as www.pdf as a website.
  2. tryConvertEncoding calls stringToUTF8String for malformed PDFs that stored UTF-8 bytes despite the specification's 7-bit ASCII URI expectation. Conversion failure is deliberately ignored, leaving the original string for normal URL parsing.

The final step is small but decisive:

const absoluteUrl = baseUrl ? URL.parse(url, baseUrl) : URL.parse(url);
return _isValidProtocol(absoluteUrl) ? absoluteUrl : null;

URL.parse resolves relative input when a usable absolute base is supplied, and protocol validation happens after resolution. A falsey input or an unparseable/disallowed URL produces null.

A real trust-boundary example appears in core/catalog.js: link parsing passes both recovery options, assigns resultObj.url only when validation succeeds, and separately preserves the original value as resultObj.unsafeUrl. The raw PDF value is not silently rewritten—but neither is it promoted to the safe navigation field.

updateUrlHash and stripPath

updateUrlHash first tries URL.parse, updates the parsed URL's hash, and returns href. Its optional relative-URL branch is more subtle:

if (allowRel && createValidAbsoluteUrl(url, 'http://example.com')) {
  return url.split('#', 1)[0] + `${hash ? `#${hash}` : ''}`;
}
return '';

The dummy origin is used only to validate a well-formed relative reference. The returned value remains relative; it is not an unexpected http://example.com URL. Setting hash to an empty string removes the fragment. Without allowRel, an input that cannot be parsed absolutely returns an empty string. display/filter_factory.js uses this helper to remove a URL fragment when establishing its base URL.

stripPath has a much narrower contract: it returns everything after the final forward slash. It is not a general filesystem-path normalizer and does not treat backslashes as separators.

shadow: replacing a getter with a cached value

Many PDF.js objects expose expensive or one-shot values through getters. shadow replaces such a property with a defined data value and returns that value:

function shadow(obj, prop, value, nonSerializable = false) {
  if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) {
    assert(prop in obj, `shadow: Property "${prop && prop.toString()}" not found in object.`);
  }
  Object.defineProperty(obj, prop, {
    value,
    enumerable: !nonSerializable,
    configurable: true,
    writable: false,
  });
  return value;
}

The result is non-writable but configurable, and it is non-enumerable when nonSerializable is true. In testing, prop in obj catches misspelled or entirely new properties: the helper is meant to shadow an existing accessor, not invent arbitrary API surface. FeatureTest, WorkerTransport.annotationStorage, and numerous other getters reuse this exact caching mechanism.

Byte Strings, UTF-8, and Unicode Normalization

Binary strings are not Unicode text

PDF internals often represent a sequence of bytes as a JavaScript string whose code units are in the byte range. bytesToString and stringToBytes serve that representation; they are not general UTF-8 encoder/decoder functions.

For short input, bytesToString can call String.fromCharCode.apply directly. Larger input is chunked to avoid exceeding engine argument-count limits:

const MAX_ARGUMENT_COUNT = 8192;
if (length < MAX_ARGUMENT_COUNT) {
  return String.fromCharCode.apply(null, bytes);
}
const strBuf = [];
for (let i = 0; i < length; i += MAX_ARGUMENT_COUNT) {
  const chunkEnd = Math.min(i + MAX_ARGUMENT_COUNT, length);
  const chunk = bytes.subarray(i, chunkEnd);
  strBuf.push(String.fromCharCode.apply(null, chunk));
}
return strBuf.join('');

The fast path accepts array-like objects with a numeric length; the chunked path additionally requires subarray, which typed arrays provide. stringToBytes performs the inverse storage operation into a new Uint8Array, keeping only the low eight bits of each code unit with charCodeAt(i) & 0xff.

Callers reflect this binary contract. The parser and font code use bytesToString for signatures and font data, while getDataProp uses stringToBytes when binary PDF input arrives as a string. Neither helper should be substituted for TextDecoder when the data format actually requires character-set decoding.

The legacy UTF-8 conversion pair

stringToUTF8String and utf8StringToString convert between UTF-8 byte strings and ordinary JavaScript strings:

function stringToUTF8String(str) {
  return decodeURIComponent(escape(str));
}
 
function utf8StringToString(str) {
  return unescape(encodeURIComponent(str));
}

These functions rely on legacy escape/unescape, but their direction is clear: the first decodes UTF-8 bytes held in a binary string; the second encodes a JavaScript string back into that byte-string form. Malformed input can make the percent-decoding step throw. Higher-level code such as metadata, XMP, datasets, and password handling uses these where the surrounding format has already established the expected representation.

normalizeUnicode: selective NFKC, not blanket normalization

Running NFKC over every extracted text chunk would do unnecessary work and could change characters PDF.js does not intend to fold. normalizeUnicode instead lazily constructs a regular expression covering the specific BMP characters and presentation forms known to require normalization, then replaces matching runs with their NFKC form. A separate map handles ſt as ſt.

The important design point is selectivity. In core/evaluator.js, text chunks are joined, passed through normalizeUnicode unless disableNormalization is set, and only then sent to the bidi algorithm. Normalization is therefore part of text-extraction policy, not an implicit side effect of every string helper in this module.

Feature Detection and Environment Boundaries

isNodeJS: rejecting browser lookalikes

isNodeJS is computed once at module evaluation. It requires a generic or unbuilt source context, a genuine-looking process object, and the absence of NW.js. Electron renderer-like processes are excluded, while Electron's browser process type remains eligible.

That extra care matters because NW.js and Electron can expose Node-shaped objects inside a browser context. A simple typeof process !== "undefined" check would select Node-only factories or networking behavior in the wrong realm. display/node_utils.js relies on the result before loading Node canvas facilities, and display/api.js uses it for defaults such as workers, fetch, and font handling.

FeatureTest: probe once, cache with shadow

Each FeatureTest static getter computes a result and shadows itself on the class. Subsequent reads do not repeat the probe:

GetterProbe and purpose
isLittleEndianWrites byte 1 into a Uint8Array, reads the same buffer as Uint32Array, and compares with 1; image pixel packing depends on the answer
isOffscreenCanvasSupportedChecks for OffscreenCanvas; used by canvas and image paths
isImageDecoderSupportedChecks for the ImageDecoder global; this is API-presence detection, not a claim that every codec is supported
isFloat16ArraySupportedChecks for Float16Array, allowing float16-specific transform and font paths
isSanitizerSupportedChecks for the Sanitizer global
platformDerives OS booleans from navigator.platform and navigator.userAgent; Firefox is detected from the user agent or forced for MOZCENTRAL builds
isCanvasFilterSupportedObtains an Offscreen or DOM 2D context and tests whether the spec-defined default filter property exists
isAlphaColorInputSupportedCreates a color input with an alpha attribute and checks whether an eight-digit color retains alpha rather than becoming #ff0000
isBackdropFilterSupportedUses CSS.supports("backdrop-filter", "blur(1px)") when CSS exists

Two probes demonstrate why existence checks are not always enough. For canvas filters, Safari can acquire an ordinary JavaScript filter property after assignment without actually implementing Canvas2D filtering. Testing the default value before assignment avoids that false positive. For alpha color input, the implementation checks behavior—what value the control retains—instead of merely checking that an input element can be created.

The cache is a deliberate tradeoff: platform and capability answers are treated as stable for the lifetime of the realm. It avoids repeated DOM/canvas setup, but it also means these getters are not intended to observe dynamic changes after their first read.

Affine Transforms and Scale Recovery

Util represents a 2D affine transform as [a, b, c, d, e, f], matching canvas and PDF conventions:

x' = m[0] * x + m[2] * y + m[4]
y' = m[1] * x + m[3] * y + m[5]

Util.transform(m1, m2) returns the composition equivalent to applying m2 and then m1. multiplyByDOMMatrix performs the same array-times-matrix operation using md.a through md.f, avoiding a conversion of the DOMMatrix into another six-element array. canvas_dependency_tracker.js uses this form directly.

The point routines have more varied mutation contracts:

MethodResult and mutation contract
applyTransform(p, m, pos = 0)Mutates one coordinate pair in p; pos permits packed coordinate arrays
applyTransformToBezier(p, transform, pos = 0)Mutates three consecutive coordinate pairs: the two control points and endpoint stored for a PDF curveTo; the current point comes from preceding path data
inverseTransform(m)Returns a new six-element inverse matrix
applyInverseTransform(p, m)Mutates one pair using the inverse equations directly
singularValueDecompose2dScale(matrix, output)Writes two scale components into the supplied output array

Both inverse paths divide by the determinant m[0] * m[3] - m[1] * m[2]; they do not handle a singular matrix. Invertibility is therefore a caller precondition, not something these hot-path helpers repair.

singularValueDecompose2dScale is more than a width/height approximation. It forms the symmetric product of the linear 2×2 portion, solves for its eigenvalues, and takes square roots to obtain singular-value scales. This remains meaningful under rotation and skew, unlike reading only m[0] and m[3]. The || 1 fallback prevents an exactly zero scale component from propagating. Canvas and pattern code use the resulting scales for scale-sensitive drawing decisions.

The allocation pattern is consistent: composition returns a new array, point application mutates existing packed buffers, and scale recovery writes to an output buffer. Those choices let rendering hot paths control allocation rather than creating temporary arrays for every operation.

Rectangles and Exact Bounding Boxes

Accumulated bounds and rectangle operations

Bounding-box routines use [minX, minY, maxX, maxY] and expand a caller-provided accumulator:

const BBOX_INIT = [Infinity, Infinity, -Infinity, -Infinity];
const F32_BBOX_INIT = new Float32Array(BBOX_INIT);

The infinities make the first point or rectangle replace the empty bounds naturally. Callers normally slice() the template or copy it into a typed array before mutation; the exported template itself should not become one document's accumulated result.

pointBoundingBox and rectBoundingBox update all four extrema in place. axialAlignedBoundingBox(rect, transform, output) transforms rectangle corners and unions them into output. When m1 and m2 are both zero, it uses a scaling/translation fast path; otherwise it includes the rotation/skew terms and all four corners. This is why transformed bounds remain axis-aligned without assuming that two opposite corners are sufficient.

Other rectangle APIs deliberately differ:

  • normalizeRect clones the input and swaps reversed coordinates in the clone, leaving the original untouched.
  • intersect handles reversed endpoints through Math.min/Math.max, returns null only when an axis is disjoint, and returns a zero-width or zero-height rectangle when boxes merely touch.
  • axialAlignedBoundingBox, pointBoundingBox, and rectBoundingBox mutate their accumulator rather than returning a fresh rectangle.

Canvas clipping, annotation rectangles, patterns, operator-list bounds, and the dependency tracker all build on these conventions. core/document.js, for example, intersects crop and media boxes; display code intersects computed bounds with canvas or clip bounds to avoid unnecessary work.

Cubic Bézier bounds without flattening the curve

A cubic curve's control-point hull is easy to compute but can be much larger than the curve's actual bounds. Util.bezierBoundingBox instead includes the two endpoints, finds derivative extrema independently for x and y, and evaluates only roots strictly inside the open interval from 0 to 1.

The private helpers handle three numerical branches:

  1. a near-zero quadratic coefficient falls back to the linear root when possible;
  2. a negative discriminant contributes no real extrema;
  3. otherwise both quadratic roots are tested, while #getExtremumOnCurve rejects roots outside the curve interval.

This is analytic bound computation, not curve flattening or sampling. core/evaluator.js uses it while accumulating path bounds, and the ink/free-draw editor code uses the same routine for user-drawn geometry. One implementation therefore keeps parser and editor bounds consistent.

Destination Validation, Identity, and Collection Factories

_isValidExplicitDest: shared structure, injected PDF types

An explicit destination is structurally an array [page, zoom, ...args]. Validation needs PDF reference and name types, but importing core primitives into this shared module would create the wrong dependency direction. _isValidExplicitDest(validRef, validName, dest) instead receives the two predicates.

The same algorithm then runs on both sides of the architecture:

  • core/catalog.js binds the real Ref test and isName predicate;
  • display/api_utils.js binds structural proxies with integer num/gen fields and a string name.

The validator checks structure, not whether a page actually exists. The page must satisfy validRef or be an integer; zoom must satisfy validName; then the zoom name determines the argument contract:

ZoomAccepted arguments after zoom
XYZTwo or three numeric/nullable arguments
Fit, FitBNo arguments
FitH, FitBH, FitV, FitBVZero or one numeric/nullable argument
FitRExactly four numeric arguments; null is not allowed
Anything elseInvalid

All other accepted arguments must be numbers, with null allowed except for FitR. Catalog destination parsing and display-side validation consequently agree on shape without sharing concrete worker-only classes.

Opaque identity, shallow equality, and reusable factories

getUuid prefers crypto.randomUUID() in Firefox builds or whenever that function exists. Its fallback generates 32 random bytes and returns bytesToString(buf):

function getUuid() {
  if (
    (typeof PDFJSDev !== 'undefined' && PDFJSDev.test('MOZCENTRAL')) ||
    typeof crypto.randomUUID === 'function'
  ) {
    return crypto.randomUUID();
  }
  const buf = new Uint8Array(32);
  crypto.getRandomValues(buf);
  return bytesToString(buf);
}

The fallback is an opaque 32-code-unit random identifier, not a hyphenated RFC UUID. Callers must rely on uniqueness, not formatting. ImageManager in the editor combines this random base with an incrementing counter to form image IDs.

Three smaller helpers also have precise limits:

  • isArrayEqual compares lengths and indexed elements with !==. It is shallow, works for array-like indexed sequences such as typed arrays, and does not recursively compare nested arrays.
  • makeArr, makeMap, makeObj, and makeSet are shared factory functions for Map/RefCache computed-insertion APIs. Reusing them avoids creating a new closure at every call site.
  • makeObj returns Object.create(null), producing a dictionary without inherited keys such as constructor or toString.

Real call sites pass these factories to getOrInsertComputed or related cache APIs—for example, nested bitmap caches, popup arrays, annotation-storage maps, and glyph sets. The factory's job is only to create the empty value; ownership and insertion remain with the collection API.

Runtime Compatibility Patches

The file ends with three minimal standard-library shims. They centralize compatibility so feature-specific modules can call the modern API directly.

For Blob and Response, a missing .bytes() method is implemented in terms of .arrayBuffer():

Blob.prototype.bytes = async function () {
  return new Uint8Array(await this.arrayBuffer());
};

The patches are installed only in built contexts where PDFJSDev exists, Babel processing is not skipped, and the native method is absent. Existing native implementations are never replaced. The returned value is consistently a Uint8Array, which is what PDF writer, image, and fetch-related callers consume.

Iterator.prototype.join has a separate fallback:

if (typeof Iterator.prototype.join !== 'function') {
  // eslint-disable-next-line no-extend-native
  Iterator.prototype.join = function (separator) {
    return [...this].join(separator);
  };
}

This shim is intentionally simple: it exhausts the iterator into an array and delegates to Array.prototype.join. That provides the expected string operation, but it is neither streaming nor constant-memory and is suitable only for finite iterators. The tradeoff is explicit in the implementation—minimal semantics in one place, rather than scattered iterator-to-array conversions.

These patches do modify global prototypes, so their placement at module scope is significant. They are compatibility infrastructure with realm-wide effects, not pure helper functions like stringToBytes or Util.transform.

Summary

util.js is the shared contract layer beneath PDF.js's more visible parser and renderer architecture. Its most important lessons are not individual convenience functions, but the boundaries those functions preserve:

  1. Numbers are interfaces. PDF flags, text-mode bit packing, OPS compatibility gaps, and dense DrawOPS values must be interpreted according to their consumers.
  2. Errors carry decisions. Exception names and extra fields survive worker transport so callers can distinguish passwords, invalid documents, response failures, cancellation, and unknown errors.
  3. Validation does not mean silent rewriting. URL recovery is narrowly scoped, protocol allowlisting happens after parsing, and raw unsafe values remain separate from validated navigation URLs.
  4. Binary strings and Unicode are different representations. Byte truncation, chunked fromCharCode, explicit UTF-8 conversion, and selective NFKC normalization solve different problems and are not interchangeable.
  5. Host adaptation is centralized. isNodeJS, cached FeatureTest probes, and small prototype shims prevent environment branches from leaking throughout rendering code.
  6. Geometry APIs expose their cost model. New arrays, in-place point updates, output accumulators, analytic Bézier extrema, and lazy caches are deliberate choices for hot rendering paths.

Once these contracts are clear, many apparently unrelated PDF.js modules become easier to read: the same flags determine cache behavior, the same exceptions cross threads, the same matrix layout drives text and patterns, and the same bounding-box accumulator serves parser and editor geometry.

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