StreamPDFStreamPDF
Back to blog

PDF.js Deep Dive 9: CanvasGraphics

October 26, 2026 · StreamPDF Team

PDF.js Deep Dive 9: CanvasGraphics

Introduction

This is the ninth article in the PDF.js series. It covers the CanvasGraphics class in the main-thread rendering layer, src/display/canvas.js.

The previous articles explained where the data comes from:

  • Article 3 explains how render() creates a RenderTask and pulls the operatorList to the main thread as a stream via sendWithStream.
  • Article 7 explains how the worker-side PartialEvaluator turns a PDF content stream into an operatorList — colors are already converted to RGB hex, fonts are already translated into FontFaceObjects, and images are already decoded into RGBA/bitmaps.
  • Article 8 explains how Page.getOperatorList() prepares the Resources, annotation layers, and rendering intent for that list.

This article covers the last link in the rendering loop: once the main thread has the operatorList, how does it actually paint it onto a canvas?

In one sentence, CanvasGraphics is an operator virtual machine — operatorList.fnArray[i] is the opcode, operatorList.argsArray[i] is the operand, and every method on CanvasGraphics implements one opcode (the method name matches the OPS constant). Running the loop renders the page.

Module Overview

canvas.js is roughly 4,400 lines and splits into five parts:

BlockContentsResponsibility
Constants and helpersMIN_FONT_SIZE, drawImageAtIntegerCoords, putBinaryImageData, etc.Noise reduction and pixel-level precision fixes
CanvasExtraStateThe "PDF extension slot" of the graphics stateStores PDF state that canvas cannot represent natively
CanvasGraphicsThe main class, roughly 100 opcode methodsThe operator virtual machine
Pattern helpersTilingPattern / getShadingPattern from pattern_helper.jsTiling patterns and gradients
Text renderingshowText / showType3TextGlyph painting

Constants

// Minimal font size that would be used during canvas fillText operations.
const MIN_FONT_SIZE = 16;
// Maximum font size that would be used during canvas fillText operations.
const MAX_FONT_SIZE = 100;
 
// Defines the time the `executeOperatorList`-method is going to be executing
// before it stops and schedules a continue of execution.
const EXECUTION_TIME = 15; // ms
// Defines the number of steps before checking the execution time.
const EXECUTION_STEPS = 10;
 
const FULL_CHUNK_HEIGHT = 16;

These four constants address three problems:

  1. Font scaling range (MIN_FONT_SIZE / MAX_FONT_SIZE). Canvas fillText loses precision at extreme font sizes (anti-aliasing disappears when too small, and glyph metric errors grow when too large). PDF.js draws between 16px and 100px first, then scales into place with ctx.scale — the fontSizeScale field holds the state for this mechanism.
  2. Time slice (EXECUTION_TIME / EXECUTION_STEPS). executeOperatorList is a synchronous loop, and a page of complex vector art can take hundreds of milliseconds. It checks the elapsed time every 10 steps; past 15ms it calls continueCallback to yield the main thread and avoid blocking the UI or user input.
  3. Chunk height (FULL_CHUNK_HEIGHT). putBinaryImageData writes ImageData into the canvas in batches of 16 rows. putImageData blocks on very large images, so chunking lets the browser interleave painting and lowers peak memory per call.

drawImageAtIntegerCoords

This is the pixel alignment fix for image drawing. When canvas drawImage lands on fractional coordinates, the browser applies bilinear interpolation, which makes images blurry or produces 1px seams (especially visible when adjacent images tile together).

function drawImageAtIntegerCoords(ctx, srcImg, srcX, srcY, srcW, srcH,
                                   destX, destY, destW, destH) {
  const [a, b, c, d, tx, ty] = getCurrentTransform(ctx);
  if (b === 0 && c === 0) {
    // Pure scale/translate matrix: round the destination rect to whole pixels
    const tlX = destX * a + tx;
    const rTlX = Math.round(tlX);
    const tlY = destY * d + ty;
    const rTlY = Math.round(tlY);
    const brX = (destX + destW) * a + tx;
 
    // Some pdf contains images with 1x1 images so in case of 0-width after
    // scaling we must fallback on 1 to be sure there is something.
    const rWidth = Math.abs(Math.round(brX) - rTlX) || 1;
    const brY = (destY + destH) * d + ty;
    const rHeight = Math.abs(Math.round(brY) - rTlY) || 1;
 
    ctx.setTransform(Math.sign(a), 0, 0, Math.sign(d), rTlX, rTlY);
    ctx.drawImage(srcImg, srcX, srcY, srcW, srcH, 0, 0, rWidth, rHeight);
    ctx.setTransform(a, b, c, d, tx, ty);
 
    return [rWidth, rHeight];
  }
  // Non-axis-aligned matrix: let the browser handle it
  ctx.drawImage(srcImg, srcX, srcY, srcW, srcH, destX, destY, destW, destH);
  ...
}

The key detail is that it rounds the top-left and bottom-right corners separately, instead of rounding width and height:

Suppose leftX = 4.321 and width = 10.432, so rightX = 14.753. After rounding, the left edge is 4 and the right edge is 15, making the actual width 11. If width were rounded on its own (10.432 → 10), a 1px seam would appear. The source comment calls this out explicitly.

The second branch, a === 0 && d === 0, handles 90° rotation matrices (where b and c are non-zero). The third branch gives up on alignment and hands the case to the browser. The Math.sign calls keep the direction correct when the image is mirrored (a or d negative).

mirrorContextOperations

function mirrorContextOperations(ctx, destCtx) { ... }

This forwards the state-changing methods of one ctx (save/restore/transform/clip/path methods) to another ctx. It is used for soft masks: the actual content is painted onto a temporary canvas, but the graphics state must stay in sync on the main canvas, otherwise the CTM will be wrong after restore.

Only state that "cannot be copied" is mirrored — transforms, paths, and clipping. Properties that can simply be read and reassigned, such as colors and line widths, are not in the list.

The CanvasExtraState class

A <canvas> 2D context already stores most of the graphics state natively, but PDF needs far more state than canvas provides:

class CanvasExtraState {
  // Are soft masks and alpha values shapes or opacities?
  alphaIsShape = false;
 
  fontSize = 0;
  fontSizeScale = 1;
  textMatrix = null;
  textMatrixScale = 1;
  fontMatrix = FONT_IDENTITY_MATRIX;
  leading = 0;
 
  // Current point (in user coordinates)
  x = 0;
  y = 0;
 
  // Start of text line (in text coordinates)
  lineX = 0;
  lineY = 0;
 
  charSpacing = 0;
  wordSpacing = 0;
  textHScale = 1;
  textRenderingMode = TextRenderingMode.FILL;
  textRise = 0;
 
  fillColor = "#000000";
  strokeColor = "#000000";
 
  tilingPatternDims = null;
  patternFill = false;
  patternStroke = false;
 
  // Note: fill alpha applies to all non-stroking operations
  fillAlpha = 1;
  strokeAlpha = 1;
  lineWidth = 1;
  activeSMask = null;
  transferMaps = "none";
 
  minMax = F32_BBOX_INIT.slice();
 
  constructor(width, height) {
    this.clipBox = new Float32Array([0, 0, width, height]);
  }
}

These fields fall into four groups:

GroupFieldsNotes
Text statefontSize, textMatrix, charSpacing, textHScale, textRise, etc.Corresponds to the PDF Tc/Tw/Tz/TL/Ts/Tr parameters
Color and patternsfillColor, patternFill, tilingPatternDimsRecords whether the current fill/stroke is a Pattern; tilingPatternDims optimizes the tiling area
TransparencyfillAlpha, strokeAlpha, activeSMask, transferMapsCanvas has only one globalAlpha, so fill and stroke alpha must be tracked separately
GeometryclipBox, minMax, x/yClip rectangle and current path bounding box, used for dirty-rectangle optimization

The clone() implementation is worth noting:

clone() {
  const clone = Object.create(this);
  clone.clipBox = this.clipBox.slice();
  clone.minMax = this.minMax.slice();
  clone.tilingPatternDims = this.tilingPatternDims?.slice();
  return clone;
}

This is a prototype-chain shallow copy plus separate slice() calls for three TypedArrays. q/Q are the most frequent operators, and fully deep-copying state on every save would noticeably slow rendering; those few arrays are modified in place (paths accumulate), so they must be copied.

Bounding boxes and clipping

CanvasExtraState also carries three geometry methods that form the basis of the dirty-rectangle optimization:

getPathBoundingBox(pathType = PathType.FILL, transform = null) {
  const box = this.minMax.slice();
  if (pathType === PathType.STROKE) {
    if (!transform) {
      unreachable("Stroke bounding box must include transform.");
    }
    // Stroked paths can be outside of the path bounding box by 1/2 the line
    // width.
    Util.singularValueDecompose2dScale(transform, XY);
    const xStrokePad = (XY[0] * this.lineWidth) / 2;
    const yStrokePad = (XY[1] * this.lineWidth) / 2;
    box[0] -= xStrokePad;
    box[1] -= yStrokePad;
    box[2] += xStrokePad;
    box[3] += yStrokePad;
  }
  return box;
}
 
updateClipFromPath() {
  const intersect = Util.intersect(this.clipBox, this.getPathBoundingBox());
  this.startNewPathAndClipBox(intersect || [0, 0, 0, 0]);
}

A stroked path's bounding box must expand by lineWidth / 2, because the line is drawn centered on the path. The scale factors come from singularValueDecompose2dScale (singular value decomposition), so line-width anisotropy under rotation or skew matrices is handled correctly.

updateClipFromPath intersects the new clip region with the existing one. If the clip region becomes empty (intersect returns null), it is set to [0,0,0,0] and all later drawing is skipped.

Design note: CanvasExtraState combines "canvas state + PDF extension state". Anything canvas can express natively (CTM, line width, color) is delegated straight to the ctx; anything it cannot (separate fill/stroke alpha, text matrices, Pattern flags, clip bounding boxes) is stored here and pushed/popped together with save/restore. This split runs through the entire CanvasGraphics.

The CanvasGraphics class

The constructor method

constructor(
  canvasCtx,
  commonObjs,      // Document-level object pool (g_ prefix: fonts, images, patterns)
  objs,            // Page-level object pool (p{n}_ prefix)
  canvasFactory,   // Off-screen canvas allocator
  filterFactory,   // CSS filter generator (transfer functions)
  { optionalContentConfig, markedContentStack = null },
  annotationCanvasMap,
  pageColors,
  dependencyTracker,
  imagesTracker
) {
  this.ctx = canvasCtx;
  this.current = new CanvasExtraState(
    this.ctx.canvas.width,
    this.ctx.canvas.height
  );
  this.stateStack = [];
  this.pendingClip = null;
  this.pendingEOFill = false;
  this.commonObjs = commonObjs;
  this.objs = objs;
  this.canvasFactory = canvasFactory;
  this.filterFactory = filterFactory;
  this.groupStack = [];
  // Patterns are painted relative to the initial page/form transform, see
  // PDF spec 8.7.2 NOTE 1.
  this.baseTransform = null;
  this.baseTransformStack = [];
  this.groupLevel = 0;
  this.smaskStack = [];
  this.tempSMask = null;
  ...
  this.suspendedCtx = null;
  this.contentVisible = true;
  this.cachedPatterns = new Map();
  this.annotationCanvasMap = annotationCanvasMap;
  this.viewportScale = 1;
  this.outputScaleX = 1;
  this.outputScaleY = 1;
  ...
  this._cachedScaleForStroking = [-1, 0];
  this._cachedGetSinglePixelWidth = null;
  this._cachedBitmapsMap = new Map();
 
  this.dependencyTracker = dependencyTracker ?? null;
  this.imagesTracker = imagesTracker ?? null;
}

By responsibility, the fields fall into five groups:

GroupFieldsNotes
Drawing contextctx, suspendedCtx, current, stateStackThe main ctx and the "suspended" ctx used in soft-mask mode
Object poolscommonObjs, objsFonts/images/patterns transferred from the worker, fetched lazily by ID
Off-screen resourcescanvasFactory, groupStack, smaskStack, cachedPatternsTemporary canvases and caches for transparency groups, soft masks, and gradients
Transform baselinebaseTransform, baseTransformStack, viewportScale, outputScaleX/YCoordinate baseline for pattern tiling and annotation canvases
Optimization caches_cachedScaleForStroking, _cachedGetSinglePixelWidth, _cachedBitmapsMapMemoization for single-pixel line width and bitmap reuse

Three details:

  1. suspendedCtx is the core of the soft-mask mechanism. After entering SMask mode, the main ctx is "suspended" and all drawing goes to a temporary canvas; suspendedCtx holds the suspended one, and the inSMaskMode getter simply returns !!this.suspendedCtx.
  2. baseTransform must be tracked separately. PDF spec 8.7.2 NOTE 1 specifies that pattern coordinates are relative to the initial page/form transform, not the current CTM, so every Form XObject entry pushes it and every exit pops it.
  3. contentVisible is the switch wired to optional content (OCG). When a layer is turned off it becomes false, and nearly every drawing method starts with if (!this.contentVisible) return; — the cheapest possible "skip".

The getObject method

getObject(opIdx, data, fallback = null) {
  if (typeof data === "string") {
    this.dependencyTracker?.recordNamedDependency(opIdx, data);
    return data.startsWith("g_")
      ? this.commonObjs.get(data)
      : this.objs.get(data);
  }
  return fallback;
}

When an operatorList carries an image or pattern, the argument can take three forms:

  • String ID: fetch from an object pool. The g_ prefix goes to commonObjs (document level); everything else goes to objs (page level).
  • Inline object: the data itself (for example the imgData passed to paintInlineImageXObject).
  • Anything else: return fallback.

The object pool's get(id, callback) parks the callback when the object has not arrived yet — exactly what the OPS.dependency branch in executeOperatorList does: once a dependency is not ready, it registers continueCallback and aborts the current execution round, then resumes from the same index when the data arrives.

The beginDrawing method

beginDrawing({ transform, viewport, transparency = false, background = null }) {
  // For pdfs that use blend modes we have to clear the canvas else certain
  // blend modes can look wrong since we'd be blending with a white
  // backdrop.
  const width = this.ctx.canvas.width;
  const height = this.ctx.canvas.height;
 
  const savedFillStyle = this.ctx.fillStyle;
  this.ctx.fillStyle = background || "#ffffff";
  this.ctx.fillRect(0, 0, width, height);
  this.ctx.fillStyle = savedFillStyle;
 
  if (transparency) {
    const transparentCanvas = (this.transparentCanvasEntry =
      this.canvasFactory.create(width, height));
    this.compositeCtx = this.ctx;
    ({ canvas: this.transparentCanvas, context: this.ctx } = transparentCanvas);
    this.ctx.save();
    this.ctx.transform(...getCurrentTransform(this.compositeCtx));
  }
 
  this.ctx.save();
  resetCtxToDefault(this.ctx);
  if (transform) {
    this.ctx.transform(...transform);
    this.outputScaleX = transform[0];
    this.outputScaleY = transform[3];
  }
  this.ctx.transform(...viewport.transform);
  this.viewportScale = viewport.scale;
 
  this.baseTransform = getCurrentTransform(this.ctx);
}

The preparation before rendering starts does four things:

  1. Fill the backdrop. Cover the whole canvas with background or white via fillRect. The comment states why: blend modes need an opaque backdrop, otherwise modes like multiply and darken mix with transparent pixels and produce wrong results.

  2. Transparency mode. With transparency: true, an extra off-screen canvas is created, the main ctx is kept as compositeCtx, and all subsequent drawing happens off-screen; endDrawing composites it in one pass.

    The comment here points out a trade-off: a canvas with a transparent background cannot get sub-pixel text anti-aliasing (browsers only apply sub-pixel AA on opaque backgrounds). So when a page needs both blend modes and high-quality text, the content is first drawn onto a temporary transparent canvas and then composited onto the main canvas in one go. The cost is one extra full-canvas drawImage; the benefit is text edges that do not look blurry.

  3. Reset the ctx to defaults. resetCtxToDefault restores lineWidth, miterLimit, globalAlpha, globalCompositeOperation, font, and so on, because the canvas may have been polluted by a previous render.

  4. Apply transforms. First apply transform (the combination of devicePixelRatio and page rotation), then viewport.transform (PDF coordinate space → canvas coordinate space). Once both are in the CTM, the result is stored as baseTransform.

Why two separate transforms? transform is the "output scale" (high-DPI screens, devicePixelRatio), while viewport.transform is "PDF user space → device space". Keeping them separate lets outputScaleX/Y be used on its own for annotation canvas sizing.

The executeOperatorList method

This is the heart of the entire rendering layer.

executeOperatorList(operatorList, executionStartIdx, continueCallback,
                    stepper, operationsFilter) {
  const argsArray = operatorList.argsArray;
  const fnArray = operatorList.fnArray;
  let i = executionStartIdx || 0;
  const argsArrayLen = argsArray.length;
 
  // Sometimes the OperatorList to execute is empty.
  if (argsArrayLen === i) {
    return i;
  }
 
  const chunkOperations =
    argsArrayLen - i > EXECUTION_STEPS &&
    typeof continueCallback === "function";
  const endTime = chunkOperations ? Date.now() + EXECUTION_TIME : 0;
  let steps = 0;
  ...
  while (true) {
    if (stepper !== undefined) {
      if (i === stepper.nextBreakPoint) {
        stepper.breakIt(i, continueCallback);
        return i;
      }
      if (stepper.shouldSkip(i)) {
        if (++i === argsArrayLen) {
          return i;
        }
        continue;
      }
    }
 
    if (!operationsFilter || operationsFilter(i)) {
      fnId = fnArray[i];
      fnArgs = argsArray[i] ?? null;
 
      if (fnId !== OPS.dependency) {
        if (fnArgs === null) {
          this[fnId](i);
        } else {
          this[fnId](i, ...fnArgs);
        }
      } else {
        for (const depObjId of fnArgs) {
          const objsPool = depObjId.startsWith("g_") ? commonObjs : objs;
          // If the promise isn't resolved yet, add the continueCallback
          // to the promise and bail out.
          if (!objsPool.has(depObjId)) {
            objsPool.get(depObjId, continueCallback);
            return i;
          }
        }
      }
    }
 
    i++;
 
    if (i === argsArrayLen) {
      return i;
    }
 
    // If the execution took longer then a certain amount of time and
    // `continueCallback` is specified, interrupt the execution.
    if (chunkOperations && ++steps > EXECUTION_STEPS) {
      if (Date.now() > endTime) {
        continueCallback();
        return i;
      }
      steps = 0;
    }
  }
}

The key points, one by one:

① The opcode is the method name

if (fnArgs === null) {
  this[fnId](i);
} else {
  this[fnId](i, ...fnArgs);
}

fnArray[i] stores the numeric ID of an OPS entry (for example OPS.fill === 22, OPS.showText === 44). this[fnId] resolves to a method thanks to this registration block at the end of canvas.js:

// src/display/canvas.js (end of file, after the class definition)
for (const op in OPS) {
  if (CanvasGraphics.prototype[op] !== undefined) {
    CanvasGraphics.prototype[OPS[op]] = CanvasGraphics.prototype[op];
  }
}

It walks every name → number pair in OPS, and when CanvasGraphics has a method with that name, it adds another alias on the prototype: "number → that method". So this[22] is equivalent to this.fill, and this[44] to this.showText.

This is the main thread running through the whole article: every opcode has a method of the same name. It buys two things:

  • Dispatch is three hash lookups, with none of the item-by-item comparison overhead of a switch.
  • Adding an operator only requires "one OPS definition + one method of the same name"; the alias loop wires it up automatically and executeOperatorList never changes.

The first argument is always opIdx (the current operator index), used by dependencyTracker and dirty-rectangle tracking.

② OPS.dependency: object prefetch and resumable interruption

The worker inserts a dependency operator before actually using images or fonts, listing every object ID that is about to be needed. When the main thread sees it, it checks the object pools one by one:

  • All ready → skip and continue.
  • Something missing → objsPool.get(depObjId, continueCallback) registers the callback, then return i hands the current index back.

The caller (InternalRenderTask.continueCallback) calls executeOperatorList again with executionStartIdx = i once the data arrives — resuming from the breakpoint, without re-running operators that already executed.

③ Yielding the time slice

const chunkOperations =
  argsArrayLen - i > EXECUTION_STEPS && typeof continueCallback === "function";
const endTime = chunkOperations ? Date.now() + EXECUTION_TIME : 0;

Chunking is enabled only when "more than 10 operators remain" and "a continuation callback was provided". Every 10 steps it checks Date.now() > endTime (15ms); on timeout it calls continueCallback() and returns the current i.

This is cooperative scheduling: CanvasGraphics does not await; it makes rendering interruptible through a returned index plus an external re-invocation. The benefit is that input events and animation frames still get processed while rendering, so scrolling does not freeze.

④ stepper and operationsFilter

  • stepper: used for "grouped rendering" — for example rendering only a node in the dependency tree (PDF.js renderInteractiveForms, partial repaints in the editor). shouldSkip(i) skips operators that are not needed, and nextBreakPoint forces an interruption when reached.
  • operationsFilter: a finer-grained filter; returning false means this entry is not decoded or executed.

Both are optional; ordinary rendering passes undefined and takes the shortest path.

The endDrawing method

#restoreInitialState() {
  // Finishing all opened operations such as SMask group painting.
  while (this.stateStack.length || this.inSMaskMode) {
    this.restore();
  }
 
  this.current.activeSMask = null;
  this.ctx.restore();
 
  if (this.transparentCanvas) {
    this.ctx = this.compositeCtx;
    this.ctx.save();
    this.ctx.setTransform(1, 0, 0, 1, 0, 0); // Avoid apply transform twice
    this.ctx.drawImage(this.transparentCanvas, 0, 0);
    this.ctx.restore();
    this.canvasFactory.destroy(this.transparentCanvasEntry);
    this.transparentCanvas = null;
    this.transparentCanvasEntry = null;
  }
}
 
endDrawing() {
  this.#restoreInitialState();
 
  // Destroy all smask group canvases now that rendering is complete.
  for (const canvas of this.smaskGroupCanvases) {
    this.canvasFactory.destroy(canvas);
  }
  ...
}

Teardown does two things: close any state left open and return temporary resources.

while (this.stateStack.length || this.inSMaskMode) this.restore(); handles malformed PDFs — content streams where q outnumbers Q (article 7 noted that the worker also appends restore operators, but residue can still exist at runtime). After all restores, ctx.restore() returns to the level that save() pushed in beginDrawing.

In transparency mode, the off-screen canvas is composited: setTransform(1,0,0,1,0,0) is the key — the off-screen canvas has the same dimensions as the main canvas, so compositing must not apply the page transform again or the result is scaled twice.

Off-screen canvases are always returned through canvasFactory.destroy(). PDF.js's canvasFactory is an object pool with a capacity limit (10 by default); reuse avoids repeatedly allocating large chunks of GPU memory, and "destroy" means returning it to the pool rather than freeing memory.

Graphics state operators

The save / restore methods

save(opIdx) {
  if (this.inSMaskMode) {
    // SMask mode may be turned on/off causing us to lose graphics state.
    // Copy the temporary canvas state to the main(suspended) canvas to keep
    // it in sync.
    copyCtxState(this.ctx, this.suspendedCtx);
  }
  this.ctx.save();
  const old = this.current;
  this.stateStack.push(old);
  this.current = old.clone();
  this.dependencyTracker?.save(opIdx);
}
 
restore(opIdx) {
  this.dependencyTracker?.restore(opIdx);
 
  if (this.stateStack.length === 0) {
    if (this.inSMaskMode) {
      this.endSMaskMode();
    }
    return;
  }
 
  this.current = this.stateStack.pop();
  this.ctx.restore();
  if (this.inSMaskMode) {
    copyCtxState(this.suspendedCtx, this.ctx);
    this.ctx.setTransform(this.suspendedCtx.getTransform());
  }
  this.checkSMaskState(opIdx);
 
  // Ensure that the clipping path is reset (fixes issue6413.pdf).
  this.pendingClip = null;
 
  this._cachedScaleForStroking[0] = -1;
  this._cachedGetSinglePixelWidth = null;
}

These map to PDF's q/Q and work as two synchronized stacks:

StackContentsMaintained by
ctx internal state stackCTM, line width, color, clipping pathCanvas natively, via ctx.save() / ctx.restore()
stateStackCanvasExtraStateCanvasGraphics itself

save pushes both; restore pops both. Note this.current = old.clone() — the old object goes onto the stack, and the current state becomes its clone. That way restore can assign the old object straight back without copying in reverse.

Three extra actions:

  1. Soft-mask synchronization. In inSMaskMode, the main ctx is suspended and drawing happens on the temporary ctx, while save/restore calls may occur in either context. copyCtxState syncs the state across before switching; after restore, setTransform explicitly realigns the CTM (the comment explains why: the temporary canvas may have just been created, so its save/restore stack is empty and ctx.restore() becomes a no-op).
  2. checkSMaskState. Re-evaluates whether to enter or leave soft-mask mode after certain state changes.
  3. Clear clipping and caches. pendingClip = null fixes issue6413.pdf; both stroke caches are invalidated with -1 and recomputed on next use.

When the stack is already empty, restore does not throw: it tries to leave SMask mode and then returns silently — a fault-tolerance choice.

The transform method

transform(opIdx, a, b, c, d, e, f) {
  this.dependencyTracker?.recordIncrementalData("transform", opIdx);
  this.ctx.transform(a, b, c, d, e, f);
 
  this._cachedScaleForStroking[0] = -1;
  this._cachedGetSinglePixelWidth = null;
}

This maps to the cm operator. The actual matrix multiplication is left to canvas; PDF.js only records dependency increments and invalidates two stroke caches.

Those caches must be invalidated because "single-pixel line width" depends on the CTM's scale factor — see getSinglePixelWidth.

The setLineWidth method

setLineWidth(opIdx, width) {
  this.dependencyTracker?.recordSimpleData("lineWidth", opIdx);
  if (width !== this.current.lineWidth) {
    this._cachedScaleForStroking[0] = -1;
  }
  this.current.lineWidth = width;
  this.ctx.lineWidth = width;
}

This maps to the w operator. lineWidth is written to both current and ctx: the former feeds bounding-box math (stroke expansion by lineWidth / 2), the latter feeds canvas drawing.

PDF line width is in user-space units, and canvas lineWidth is also affected by the CTM, so the semantics match and a direct assignment is correct.

The setDash method

setDash(opIdx, dashArray, dashPhase) {
  this.dependencyTracker?.recordSimpleData("dash", opIdx);
  const ctx = this.ctx;
  if (ctx.setLineDash !== undefined) {
    ctx.setLineDash(dashArray);
    ctx.lineDashOffset = dashPhase;
  }
}

This maps to the d operator. PDF dash syntax is [dashArray] dashPhase, which corresponds one-to-one with canvas setLineDash / lineDashOffset, so no conversion is needed.

Note the compatibility check if (ctx.setLineDash !== undefined) — a very old leftover from early Safari versions that lacked the method.

The setGState method

setGState(opIdx, states) {
  for (const [key, value] of states) {
    switch (key) {
      case "LW":
        this.setLineWidth(opIdx, value);
        break;
      case "LC":
        this.setLineCap(opIdx, value);
        break;
      case "LJ":
        this.setLineJoin(opIdx, value);
        break;
      case "ML":
        this.setMiterLimit(opIdx, value);
        break;
      case "D":
        this.setDash(opIdx, value[0], value[1]);
        break;
      case "RI":
        this.setRenderingIntent(opIdx, value);
        break;
      case "FL":
        this.setFlatness(opIdx, value);
        break;
      case "Font":
        this.setFont(opIdx, value[0], value[1]);
        break;
      case "CA":
        this.current.strokeAlpha = value;
        break;
      case "ca":
        this.ctx.globalAlpha = this.current.fillAlpha = value;
        break;
      case "BM":
        this.ctx.globalCompositeOperation = value;
        break;
      case "SMask":
        this.current.activeSMask = value ? this.tempSMask : null;
        if (this.current.activeSMask) {
          this.current.activeSMask.blendMode =
            this.ctx.globalCompositeOperation;
        }
        this.tempSMask = null;
        this.checkSMaskState(opIdx);
        break;
      case "TR":
        this.ctx.filter = this.current.transferMaps =
          this.filterFactory.addFilter(value);
        break;
    }
  }
}

This maps to the gs operator. The worker (article 7's setGState) has already compiled the operation into a flat array of [key, value] pairs, so the main thread only needs to dispatch by key to the corresponding setter.

Three things worth noting:

  1. The CA / ca asymmetry. ca (fill alpha) can be written straight to globalAlpha, while CA (stroke alpha) can only be stored in current.strokeAlpha — canvas has a single globalAlpha, so it is switched temporarily while stroking:
// stroke()
ctx.globalAlpha = this.current.strokeAlpha;
this.rescaleAndStroke(path, /* saveRestore */ true);
...
// Restore the global alpha to the fill alpha
ctx.globalAlpha = this.current.fillAlpha;
  1. SMask does not take effect immediately; it only attaches tempSMask to current.activeSMask, and checkSMaskState decides whether to actually enter mask mode (because soft masks must switch canvases at q/Q boundaries).
  2. TR (transfer function) becomes a CSS filter. The 256-entry LUT computed by the worker is assembled by filterFactory.addFilter into an SVG filter string of the form url(#...), assigned straight to ctx.filter — an efficient way to hand color transforms down to the browser compositor.

The default cases (RI, FL) are empty implementations. Rendering intent and flatness have no counterpart in the browser, so they are simply ignored.

The constructPath method

constructPath(opIdx, op, data, minMax) {
  let [path] = data;
  if (!minMax) {
    // The path is empty, so no need to update the current minMax.
    path ||= (data[0] = new Path2D());
    if (op !== OPS.stroke && op !== OPS.closeStroke) {
      this.current.tilingPatternDims = null;
    }
    this[op](opIdx, path);
    return;
  }
 
  if (!(path instanceof Path2D)) {
    path = data[0] = makePathFromDrawOPS(path);
  }
  Util.axialAlignedBoundingBox(
    minMax,
    getCurrentTransform(this.ctx),
    this.current.minMax
  );
  ...
  this[op](opIdx, path);
  this._pathStartIdx = opIdx;
}

This is the unified entry point for path operators. The worker (article 7's buildPath) compresses an entire path into a single array instead of emitting moveTo/lineTo one by one — constructPath's argsArray[i] looks like [[pathData], minMax], and op is the terminating operation such as fill/stroke/eoFill.

Three design points:

  1. Path cache reuse. data[0] is a cache slot: on the first call, makePathFromDrawOPS(path) compiles the instruction array into a Path2D and writes it back to data; rendering the same page again reuses that Path2D and skips compilation.

makePathFromDrawOPS is a helper in display_utils.js that translates a flat [op, ...coords] sequence into Path2D moveTo/lineTo/bezierCurveTo calls.

  1. Bounding boxes accumulate in device space. Util.axialAlignedBoundingBox(minMax, ctm, this.current.minMax) transforms the user-space minMax into an axis-aligned box and merges it into the current path bounds. This is the data source for "dirty rectangles" and clip intersection.

  2. Fill-area optimization for tiling patterns.

const tilingDims = this.current.tilingPatternDims;
if (tilingDims && op !== OPS.stroke && op !== OPS.closeStroke &&
    this.current.fillColor instanceof TilingPattern) {
  const clippedBBox = Util.intersect(this.current.clipBox, this.current.minMax);
  if (!clippedBBox) {
    this.current.tilingPatternDims = null;
  } else {
    this.current.fillColor.updatePatternDims(clippedBBox, tilingDims);
  }
}

A tiling pattern only needs a pattern canvas as large as the "actual fill area", not the whole page. Here the clipped bounding box is converted into a tile range in pattern space, and TilingPattern uses it to decide how many rows and columns to generate.

The fill / stroke methods

fill(opIdx, path, consumePath = true) {
  const ctx = this.ctx;
  const fillColor = this.current.fillColor;
  const isPatternFill = this.current.patternFill;
  let needRestore = false;
  const intersect = this.current.getClippedPathBoundingBox();
 
  if (isPatternFill) {
    const dims = this.current.tilingPatternDims;
    const tileIdx = dims && fillColor.canSkipPatternCanvas(dims);
    if (tileIdx) {
      // Draw the tile directly, skipping the pattern canvas.
      fillColor.drawPattern(this, path, this.pendingEOFill, tileIdx, opIdx);
      ...
      return;
    }
    ctx.save();
    ctx.fillStyle = fillColor.getPattern(
      ctx, this, getCurrentTransformInverse(ctx), PathType.FILL, opIdx
    );
    needRestore = true;
  }
 
  if (this.contentVisible && intersect !== null) {
    if (this.pendingEOFill) {
      ctx.fill(path, "evenodd");
      this.pendingEOFill = false;
    } else {
      ctx.fill(path);
    }
  }
 
  if (needRestore) {
    ctx.restore();
  }
  if (consumePath) {
    this.consumePath(opIdx, path, intersect);
  }
}

Fill and stroke share several patterns:

  1. Clip-bounding-box short circuit. intersect === null means the path lies entirely outside the clip region, so ctx.fill is skipped — the most common performance optimization: content outside the page never reaches rasterization.
  2. Two paths for Pattern fills. If the pattern has only one tile and needs no tiling (canSkipPatternCanvas), the tile is drawn directly onto the path, saving a createPattern and canvas compositing; otherwise it goes through getPattern + fillStyle.
  3. pendingEOFill. Even-odd fill (f*) is determined on the worker side by the combination of PAINT_OP and PAINT_FORMAT, encoded into the operator arguments, and expressed at runtime with ctx.fill(path, "evenodd").
  4. consumePath. Actually performs clipping and path cleanup; see the next section.

stroke differs in how it switches alpha and rescales line width:

ctx.globalAlpha = this.current.strokeAlpha;
...
this.rescaleAndStroke(path, /* saveRestore */ true);
...
ctx.globalAlpha = this.current.fillAlpha;

rescaleAndStroke solves the problem of "line width distorting under non-uniform scaling" — canvas stroke scales along the CTM, and PDF semantics do the same, but an extremely small line width can shrink to 0 and disappear, so it must be normalized to "at least 1 pixel" before stroking.

The consumePath method

consumePath(opIdx, path, clipBox) {
  const isEmpty = this.current.isEmptyClip();
  if (this.pendingClip) {
    this.current.updateClipFromPath();
  }
  if (!this.pendingClip) {
    this.compose(clipBox);
  }
  const ctx = this.ctx;
  if (this.pendingClip) {
    if (!isEmpty) {
      if (this.pendingClip === EO_CLIP) {
        ctx.clip(path, "evenodd");
      } else {
        ctx.clip(path);
      }
    }
    this.pendingClip = null;
    ...
  }
  this.current.startNewPathAndClipBox(this.current.clipBox);
}

"Consuming" a path is the cleanup after drawing. Three actions:

  1. Deferred clipping. The clip/eoClip operators do not execute immediately (see the next section); they only set a pendingClip flag. The actual ctx.clip(path) happens when the next path is consumed — because PDF clipping semantics are "use the current path as the clip region, then discard the path", and canvas clip() also consumes the current path, so the timing must be aligned.
  2. Soft-mask compositing. When there is no pending clip, compose(clipBox) composites the result of this drawing operation back into the main canvas by dirty rectangle (in soft-mask mode).
  3. Reset path state. startNewPathAndClipBox resets minMax to its initial value so the next path starts fresh.

The clip / eoClip methods

clip(opIdx) {
  this.dependencyTracker?.recordFutureForcedDependency("clipMode", opIdx);
  this.pendingClip = NORMAL_CLIP;
}
 
eoClip(opIdx) {
  this.dependencyTracker?.recordFutureForcedDependency("clipMode", opIdx);
  this.pendingClip = EO_CLIP;
}

These map to W / W*. They only set a flag; they do not execute.

This is not laziness but a specification requirement: in PDF, W applies to the drawing operation that immediately follows, and that drawing operation consumes the path. If ctx.clip() were executed here, the following fill/stroke would find the path already consumed and paint nothing.

The correct order is therefore: W (set flag) → f (fill + consume path, calling ctx.clip along the way).

The isEmptyClip() check avoids calling ctx.clip() on an empty path — in some browser implementations an empty clip clips away everything.

The beginMarkedContent / endMarkedContent methods

Marked content (BDC/BMC/EMC) paints nothing itself; it is mainly used for:

  • Optional content groups (OCG): deciding whether this content is visible.
  • Accessibility structure: tagging content (paragraphs, images, tables).
  • ActualText: supplying semantic text for text replacement.

At runtime it maintains markedContentStack and only asks optionalContentConfig for visibility when tag === "OC":

beginMarkedContent(opIdx, tag) {
  this.dependencyTracker?.beginMarkedContent(opIdx);
  this.markedContentStack.push({ visible: true });
}
 
beginMarkedContentProps(opIdx, tag, properties) {
  this.dependencyTracker?.beginMarkedContent(opIdx);
  if (tag === "OC") {
    this.markedContentStack.push({
      visible: this.optionalContentConfig.isVisible(properties),
    });
  } else {
    this.markedContentStack.push({ visible: true });
  }
  this.contentVisible = this.isContentVisible();
}
 
endMarkedContent(opIdx) {
  this.dependencyTracker?.endMarkedContent(opIdx);
  this.markedContentStack.pop();
  this.contentVisible = this.isContentVisible();
}

Visibility is an AND over the whole stack — if any ancestor is invisible, the current content is invisible:

isContentVisible() {
  for (let i = this.markedContentStack.length - 1; i >= 0; i--) {
    if (!this.markedContentStack[i].visible) {
      return false;
    }
  }
  return true;
}

The if (!this.contentVisible) return; at the start of almost every drawing method is the result of that computation. When a layer is turned off, the entire chunk of content is skipped — no patterns are generated, no images are decoded, nothing reaches rasterization.

Color and patterns

The setFillRGBColor / setStrokeRGBColor methods

setFillRGBColor(opIdx, color) {
  this.dependencyTracker?.recordSimpleData("fillColor", opIdx);
  this.ctx.fillStyle = this.current.fillColor = color;
  this.current.patternFill = false;
  this.current.tilingPatternDims = null;
}
 
setStrokeRGBColor(opIdx, color) {
  this.dependencyTracker?.recordSimpleData("strokeColor", opIdx);
  this.ctx.strokeStyle = this.current.strokeColor = color;
  this.current.patternStroke = false;
}

These two lines are the endpoint of the whole "delegate color downstream" strategy.

PDF color spaces are extremely complex: DeviceRGB, DeviceCMYK, CalRGB, Lab, ICCBased, Indexed, Separation, DeviceN, Pattern... The worker (article 7's _handleColorSpace and the setFillColor family) has already converted all of them into #RRGGBB hex strings. By the time it reaches the main thread, only a string assignment remains.

This not only removes ICC color-management math from the main thread, but also lets the browser use its native GPU-accelerated compositing path.

Note the two side effects that get cleared:

  • patternFill = false — switching from a pattern back to a solid color.
  • tilingPatternDims = null — the tiling range is invalidated and recomputed on next use.

setFillTransparent / setStrokeTransparent are the special-case branch of the same pattern, setting the color directly to "transparent" (used for some fully transparent Separation cases).

The getColorN_Pattern method

getColorN_Pattern(opIdx, IR) {
  let pattern;
  if (IR[0] === "TilingPattern") {
    const baseTransform = this.baseTransform || getCurrentTransform(this.ctx);
    const canvasGraphicsFactory = {
      createCanvasGraphics: (ctx, renderingOpIdx) =>
        new CanvasGraphics(
          ctx, this.commonObjs, this.objs, this.canvasFactory, this.filterFactory,
          {
            optionalContentConfig: this.optionalContentConfig,
            markedContentStack: this.markedContentStack,
          },
          undefined, undefined,
          this.dependencyTracker
            ? new CanvasNestedDependencyTracker(
                this.dependencyTracker, renderingOpIdx,
                /* ignoreBBoxes */ true
              )
            : null
        ),
    };
    pattern = new TilingPattern(IR, this.ctx, canvasGraphicsFactory, baseTransform);
  } else {
    pattern = this._getPattern(opIdx, IR[1], IR[2]);
  }
  return pattern;
}

This maps to the SCN/scn operators (color setting with a Pattern). The worker produces an intermediate representation (IR), and dispatch here happens on the first element of that IR:

First element of IRTypeHandling
"TilingPattern"Tiling patternConstruct a TilingPattern, passing a canvasGraphicsFactory so tile content can be rendered recursively
Anything elseShading (gradient)Fetch the shading IR from the object pool and hand it to getShadingPattern

canvasGraphicsFactory is the most elegant part here: each tile of a tiling pattern is itself a PDF content stream, which must be rendered onto an off-screen canvas by a new CanvasGraphics. The factory pattern hands the decision of "how to create a child Graphics" to TilingPattern — it only needs to know that it can render an operator list onto some ctx.

baseTransform carries the initial transform of the current page/form, not the live CTM; the reasoning is in the constructor section: PDF spec 8.7.2 NOTE 1 defines pattern coordinates relative to the initial transform.

new CanvasNestedDependencyTracker(..., /* ignoreBBoxes */ true) means drawing inside a pattern does not participate in the outer dirty-rectangle computation — the pattern's bounding box is determined by the outer fill path as a whole.

The _getPattern method

_getPattern(opIdx, objId, matrix = null) {
  const pattern = this.cachedPatterns.getOrInsertComputed(objId, () =>
    getShadingPattern(this.getObject(opIdx, objId))
  );
  if (matrix) {
    pattern.matrix = matrix;
  }
  return pattern;
}

This is the object-level cache for gradient patterns. cachedPatterns keys on objId, ensuring the CanvasGradient for a given shading is resolved only once.

getOrInsertComputed is the new standard Map method (the upsert proposal): "fetch, or call the factory and write back", without the two lookups of has + get. PDF.js predefines zero-argument factories such as makeMap / makeArr in shared/util.js to avoid creating a new function on every call.

pattern.matrix can be overwritten from outside — when the same shading is used in different coordinate systems, the matrix must be reassigned.

The shadingFill method

shadingFill(opIdx, objId) {
  if (!this.contentVisible) {
    return;
  }
  const started = this.#beginKnockoutElement(this.current.fillAlpha);
  const ctx = this.ctx;
 
  this.save(opIdx);
  const pattern = this._getPattern(opIdx, objId);
  ctx.fillStyle = pattern.getPattern(
    ctx, this, getCurrentTransformInverse(ctx), PathType.SHADING, opIdx
  );
 
  const inv = getCurrentTransformInverse(ctx);
  if (inv) {
    const { width, height } = ctx.canvas;
    const minMax = F32_BBOX_INIT.slice();
    Util.axialAlignedBoundingBox([0, 0, width, height], inv, minMax);
    const [x0, y0, x1, y1] = minMax;
 
    this.ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
  } else {
    // HACK to draw the gradient onto an infinite rectangle.
    // PDF gradients are drawn across the entire image while
    // Canvas only allows gradients to be drawn in a rectangle
    ...
  }
}

This maps to the sh operator — filling the entire clip region with a gradient (not a specific path).

The distinction between PathType.SHADING and PathType.FILL matters: a pattern fill only needs to cover the path bounds, while a gradient (especially a mesh gradient) needs to know the full usable canvas area to generate a correct CanvasGradient. getPattern uses this type internally to decide the gradient endpoint coordinates.

The fill-area algorithm: take the whole canvas rectangle [0, 0, width, height], transform it back into user space through the inverse CTM to obtain an axis-aligned bounding box, then fillRect within that range. In other words, the coverage is "the projection of the currently visible canvas into user space", clipped by the current clip path — so no matter how the CTM scales or rotates, the gradient fills the entire visible area.

When inv is empty (the CTM is not invertible), the HACK branch uses an enormous rectangle to approximate an "infinite region", because PDF gradients cover the whole plane by specification while canvas can only paint within a rectangle.

TilingPattern and getShadingPattern

These two classes live in src/display/pattern_helper.js and are the two main collaborators of canvas.js.

The key capabilities of TilingPattern:

canSkipPatternCanvas(dims)   // Returns the tile index when a single tile suffices, skipping createPattern
drawPattern(graphics, path, eoFill, tileIdx, opIdx)  // Draw the tile directly onto the path
updatePatternDims(clippedBBox, dims)  // Trim extra tiles based on the actual fill area
getPattern(ctx, graphics, invTransform, pathType, opIdx)  // Produce a CanvasPattern
isModifyingCurrentTransform()  // Whether rendering the pattern changes the CTM

isModifyingCurrentTransform() corresponds to this block in fill:

const baseTransform = fillColor.isModifyingCurrentTransform()
  ? ctx.getTransform()
  : null;
ctx.save();
ctx.fillStyle = fillColor.getPattern(...);
if (baseTransform) {
  const newPath = new Path2D();
  newPath.addPath(path, ctx.getTransform().invertSelf().multiplySelf(baseTransform));
  path = newPath;
}

Some patterns modify the CTM while generating a CanvasPattern (implementation differences in pattern.setTransform). Once the CTM changes, the coordinate system of the previously built Path2D is off, so the path must be remapped with "inverse of the new CTM × old CTM".

getShadingPattern handles PDF's seven shading types:

TypeNameCanvas mapping
1Function-basedDegenerates to a solid color (the function's output at the midpoint of the interval)
2AxialcreateLinearGradient
3RadialcreateRadialGradient
4-7Free-form / Lattice / Coons / TensorCannot be expressed as a native gradient; falls back to sampling the grid points

Types 4-7 are mesh gradients and have no canvas API counterpart. PDF.js samples the shading function into a small ImageData and stretches it with createPattern — trading precision for performance.

Design note: The "heavy lifting" for colors and patterns all happens in the worker (color-space conversion, ICC profiles, shading-function sampling); the main thread only has to put it into canvas fillStyle / strokeStyle. This is a direct consequence of the "evaluation ≠ rendering" principle from article 7.

Transparency groups and soft masks

PDF's transparency model is far more complex than canvas, so CanvasGraphics maintains three mechanisms for it: groupStack (transparency groups), smaskStack (soft masks), and #knockoutGroupLevel (knockout groups).

The beginGroup method

beginGroup(opIdx, group) {
  if (!this.contentVisible) {
    return;
  }
 
  this.save(opIdx);
  // If there's an active soft mask we don't want it enabled for the group, so
  // clear it out. The mask and suspended canvas will be restored in endGroup.
  const { inSMaskMode } = this;
  if (inSMaskMode) {
    this.endSMaskMode();
    this.current.activeSMask = null;
  }
 
  const currentCtx = this.ctx;
  if (
    // A non-isolated group blends with its backdrop, so drawing it directly
    // on the parent canvas (rather than on a transparent intermediate one)
    // is correct even when it contains blend modes (bug 1873345). A soft
    // mask still needs its own canvas though, and an isolated group requires
    // a transparent backdrop, so both keep the intermediate canvas.
    (!group.needsIsolation || (!group.isolated && !group.hasSoftMask)) &&
    !group.knockout &&
    !group.isGray &&
    this.#knockoutGroupLevel === 0 &&
    currentCtx.globalAlpha === 1 &&
    currentCtx.globalCompositeOperation === "source-over" &&
    !inSMaskMode
  ) {
    if (group.bbox) { /* Clip with a Path2D */ }
    this.groupStack.push(null); // null = no intermediate canvas
    this.#groupStackMeta.push(null);
    this.groupLevel++;
    return;
  }
 
  const currentTransform = getCurrentTransform(currentCtx);
  if (group.matrix) {
    currentCtx.transform(...group.matrix);
  }
  ...
}

This maps to /Group in BDC or the transparency group of a Form XObject. The central decision in beginGroup is: allocate an off-screen canvas or not.

The fast path (no canvas allocation) requires seven conditions at once, whose essence is "this group behaves exactly like direct drawing":

  • No isolation needed (!needsIsolation, or not isolated and no soft mask) — a non-isolated group blends with its backdrop anyway, so drawing it directly on the parent canvas is correct (the referenced bug 1873345 was a case where an off-screen canvas had been allocated incorrectly).
  • Not a knockout group and not a grayscale group.
  • Not inside another knockout group.
  • Current globalAlpha === 1 and globalCompositeOperation === "source-over".
  • Not in SMask mode.

When they hold, groupStack.push(null) — null is the marker for "no intermediate canvas", and endGroup takes the fast return branch based on it. This optimization avoids allocating a full-size off-screen canvas for every Form XObject.

The slow path computes the clip bounds:

// Clip the bounding box to the current canvas.
const canvasBounds = [0, 0, currentCtx.canvas.width, currentCtx.canvas.height];
 
let bounds;
if (group.bbox) {
  bounds = F32_BBOX_INIT.slice();
  Util.axialAlignedBoundingBox(group.bbox, getCurrentTransform(currentCtx), bounds);
  bounds = Util.intersect(bounds, canvasBounds) || [0, 0, 0, 0];
}

The off-screen canvas is allocated only as large as the group bounding box, not the whole page — very effective for small transparency groups (common logos and buttons).

The endGroup method

endGroup(opIdx, group) {
  if (!this.contentVisible) {
    return;
  }
  this.groupLevel--;
  const groupCtx = this.ctx;
  const ctx = this.groupStack.pop();
  const groupMeta = this.#groupStackMeta.pop();
  if (groupMeta) {
    this.#knockoutGroupLevel = groupMeta.savedKnockoutLevel;
  }
  if (ctx === null) {
    // Simple group: content was drawn directly on the parent canvas.
    this.restore(opIdx);
    return;
  }
 
  if (group.isGray) {
    this.#convertGroupToGray(groupCtx);
  }
 
  this.ctx = ctx;
  // Turn off image smoothing to avoid sub pixel interpolation which can
  // look kind of blurry for some pdfs.
  this.ctx.imageSmoothingEnabled = false;
 
  if (group.smask) {
    this.tempSMask = this.smaskStack.pop();
    this.restore(opIdx);
    ...
  } else {
    this.ctx.restore();
    const currentMtx = getCurrentTransform(this.ctx);
    this.restore(opIdx);
    this.ctx.save();
    this.ctx.setTransform(...currentMtx);
    ...
    this.ctx.drawImage(groupCtx.canvas, 0, 0);
  }
  this.ctx.restore();
  this.canvasFactory.destroy({ canvas: groupCtx.canvas, context: groupCtx });
}

Ending a group splits into three branches by type:

BranchHandling
ctx === nullFast path. Content was drawn directly onto the parent canvas, so only restore is needed
group.smaskThe group is used as a soft mask; tempSMask is popped from smaskStack, and checkSMaskState later enters mask mode
Ordinary groupdrawImage the off-screen canvas back onto the parent canvas

imageSmoothingEnabled = false is a detail: when compositing an off-screen canvas, pixels should map 1:1, and enabling smoothing only blurs the edges.

Knockout groups (#knockoutGroupLevel > 0) are the most complex branch. Knockout semantics are "elements inside the group do not stack on each other; each blends with the group backdrop by alpha". The implementation needs three steps:

  1. Draw the group content onto a transparent scratch (at which point the elements have already stacked).
  2. Use destination-in to clip the scratch into "the shape covered by the elements", and destination-out to punch that region out of the parent canvas.
  3. Finally drawImage the result back.

The shapeMaskEntry (shape mask) + destination-in + destination-out in the code is exactly this flow. Comments explain in detail the two coordinate systems when a subgroup is nested inside a KO parent group (currentMtx for final draw positioning, groupMeta.offsetX/Y for pixel-level clipping) — this is the hardest-to-read region of canvas.js, and generations of bug fixes have left a lot of commentary there.

The beginSMaskMode / endSMaskMode methods

beginSMaskMode(opIdx) {
  if (this.inSMaskMode) {
    throw new Error("beginSMaskMode called while already in smask mode");
  }
  const { width: drawnWidth, height: drawnHeight } = this.ctx.canvas;
  const scratchCanvas = this.canvasFactory.create(drawnWidth, drawnHeight);
  this.smaskScratchCanvas = scratchCanvas;
  this.suspendedCtx = this.ctx;
  const ctx = (this.ctx = scratchCanvas.context);
  ctx.setTransform(this.suspendedCtx.getTransform());
  copyCtxState(this.suspendedCtx, ctx);
  mirrorContextOperations(ctx, this.suspendedCtx);
 
  this._ensurePreparedSMask(this.current.activeSMask);
 
  this.setGState(opIdx, [["BM", "source-over"]]);
}
 
endSMaskMode() {
  if (!this.inSMaskMode) {
    throw new Error("endSMaskMode called while not in smask mode");
  }
  // The soft mask is done, now restore the suspended canvas as the main
  // drawing canvas.
  this.ctx._removeMirroring();
  copyCtxState(this.ctx, this.suspendedCtx);
  this.ctx = this.suspendedCtx;
 
  this.suspendedCtx = null;
  this.canvasFactory.destroy(this.smaskScratchCanvas);
  this.smaskScratchCanvas = null;
  this._clearPreparedSMask();
}

Soft-mask mode = the main canvas is suspended, drawing moves to a scratch canvas, and the result is composited back by alpha afterwards.

Entering takes four steps:

  1. Create a scratch canvas the same size as the main canvas.
  2. suspendedCtx keeps the main ctx, and this.ctx switches to the scratch.
  3. Sync the CTM and state, then mirrorContextOperations(scratch, main) — subsequent transform/clip/save calls on the scratch also apply to the main ctx, so the state stack does not drift.
  4. _ensurePreparedSMask pre-bakes the mask (see below), and BM is set to source-over — mask drawing must not be disturbed by an outer blend mode.

Exiting reverses this: _removeMirroring() removes the mirroring, state is copied back, and ctx is restored.

_ensurePreparedSMask / #createKnockoutMaskCanvas

The performance key for soft masks is pre-baking.

const filterSpec = hasFilter
  ? {
      url:
        subtype === "Alpha"
          ? this.filterFactory.addAlphaFilter(transferMap)
          : this.filterFactory.addLuminosityFilter(transferMap),
      subtype,
      transferMap,
    }
  : null;

The two soft-mask subtypes, /S /Alpha and /S /Luminosity, map to canvas as follows:

SubtypeMeaningImplementation
AlphaThe mask's alpha channel is the opacitydestination-in, with an optional feComponentTransfer transfer function
LuminosityThe mask's luminosity is the opacity (0.3R + 0.59G + 0.11B)First convert RGB to grayscale alpha with a filter, then destination-in

/BC (background color) only matters for Luminosity — it participates in the luminosity computation, so it must be "baked" into the mask pixels; for Alpha, /BC is a pure color-space backdrop and must not be baked into the alpha output.

The code also applies an empirical optimization:

const SMASK_LAYER_TO_MASK_AREA_RATIO = 4;
const { width: layerW, height: layerH } = this.ctx.canvas;
const maskArea = maskCanvas.width * maskCanvas.height;
const useLayerSize = layerW * layerH < SMASK_LAYER_TO_MASK_AREA_RATIO * maskArea;

If the mask bitmap and the drawing layer are close in size (area ratio below 4), the mask is pre-baked at layer size, so compose can drawImage at identical dimensions and hit the GPU's same-size fast path; otherwise the mask keeps its original size to avoid allocating a huge buffer for a large image. The comment notes that this ratio was measured on the bug-2033095 corpus.

#createKnockoutMaskCanvas also contains a no-DOM fallback:

// No-DOM fallback (Node/embedded). Scale painted alpha back to shape
// coverage; color channels are irrelevant for destination-out/in.
const sourceData = sourceCanvas.getContext("2d", { willReadFrequently: true })
  .getImageData(0, 0, width, height);
...
for (let i = 3, ii = sourcePixels.length; i < ii; i += 4) {
  maskPixels[i] = Math.min(Math.round(sourcePixels[i] * alphaScale), 255);
}

In some environments (Node, embedded), ctx.filter can be set but has no effect. When PDF.js detects this, it uses getImageData + putImageData to scale the alpha channel by hand — slow but correct. Early Safari versions had the same lazy-filter issue, which the code detects with FeatureTest.isCanvasFilterSupported.

The compose / composeSMask methods

compose(dirtyBox) {
  if (!this.current.activeSMask) {
    return;
  }
 
  dirtyBox = dirtyBox
    ? [Math.floor(dirtyBox[0]), Math.floor(dirtyBox[1]),
       Math.ceil(dirtyBox[2]), Math.ceil(dirtyBox[3])]
    : [0, 0, this.ctx.canvas.width, this.ctx.canvas.height];
  const smask = this.current.activeSMask;
  const suspendedCtx = this.suspendedCtx;
  const applySMaskInPlace =
    this.#knockoutElementDepth > 0 && suspendedCtx === this.ctx;
 
  this.composeSMask(
    applySMaskInPlace ? null : suspendedCtx,
    smask,
    this.ctx,
    dirtyBox
  );
  if (applySMaskInPlace) {
    return;
  }
 
  // Clear the full scratch canvas, not just the dirty box. Pixels left
  // outside dirtyBox can leak into a later compose() whose destination-in
  // pass doesn't overwrite them, producing stale output -- this is what
  // breaks `firefox-issue17779-partial` (issue #21276).
  this.ctx.save();
  this.ctx.setTransform(1, 0, 0, 1, 0, 0);
  this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
  this.ctx.restore();
}

compose is called at the end of every "drawing unit" (consumePath, showText, paintImageXObject, etc.). It takes what was just drawn on the scratch canvas, composites it onto the main canvas by the soft mask's alpha, and then clears the scratch.

Three points:

  1. Rounding direction for dirtyBox. Left/top round down, right/bottom round up — ensuring the dirty rectangle fully covers the actual drawing region.
  2. applySMaskInPlace. Inside a knockout element, when the scratch is the current ctx, apply the mask in place to avoid an extra composite.
  3. Clear the entire scratch, not just dirtyBox. The comment cites issue #21276: leftover pixels outside dirtyBox leak out during the next compose's destination-in pass. A classic case of "partial cleanup is not enough; everything must be cleared".

composeSMask handles the actual compositing:

composeSMask(ctx, smask, layerCtx, layerBox) {
  const layerOffsetX = layerBox[0];
  const layerOffsetY = layerBox[1];
  const layerWidth = layerBox[2] - layerOffsetX;
  const layerHeight = layerBox[3] - layerOffsetY;
  if (layerWidth === 0 || layerHeight === 0) {
    return;
  }
 
  const preparedEntry = this.smaskPreparedEntry;
  if (preparedEntry) {
    // Fast path: prepared-mask destination-in drawImage.
    ...
  }
}

If a pre-baked mask exists, it takes the destination-in fast path (a single drawImage performs the alpha multiplication on the GPU); otherwise it falls back to per-chunk getImageData/putImageData multiplication by hand. smaskPreparedOOBAlpha handles out-of-bounds (OOB) pixels when "the mask is smaller than the layer" — three semantics (null clip everything / 255 keep / intermediate values fade out), with a detailed table in the comments.

Design note: PDF's transparency model (isolated/knockout/soft mask/blend mode) goes far beyond native canvas capabilities. PDF.js's strategy is to build that model by hand out of "off-screen canvas + compositing operators": destination-in expresses alpha masks, destination-out expresses knockout holes, copy expresses isolated clearing. The complexity of each branch comes from the imperfect alignment between "PDF semantics → canvas compositing operators".

Image drawing

In PDF, images appear as XObjects of two kinds: /Subtype /Image (ordinary images) and ImageMask (a 1-bit mask painted with the current fill color).

The _scaleImage method

_scaleImage(img, inverseTransform) {
  const width = img.width ?? img.displayWidth;
  const height = img.height ?? img.displayHeight;
  const widthScale = Math.max(Math.hypot(inverseTransform[0], inverseTransform[1]), 1);
  const heightScale = Math.max(Math.hypot(inverseTransform[2], inverseTransform[3]), 1);
 
  const scaleSteps = [];
  let ws = widthScale, hs = heightScale, pw = width, ph = height;
  while ((ws > 2 && pw > 1) || (hs > 2 && ph > 1)) {
    let nw = pw, nh = ph;
    if (ws > 2 && pw > 1) { nw = Math.ceil(pw / 2); ws /= pw / nw; }
    if (hs > 2 && ph > 1) { nh = Math.ceil(ph / 2); hs /= ph / nh; }
    scaleSteps.push({ newWidth: nw, newHeight: nh });
    pw = nw; ph = nh;
  }
  ...
}

Downsampling must be done in steps.

When a 4000×3000 scanned image is displayed at 200×150, a single drawImage shrinking it 20× would make canvas interpolation sample only a few nearby pixels, aliasing fine details into noise. Browser implementations generally only guarantee good quality within a 2× scale factor.

So _scaleImage halves step by step: at most 2× reduction per step, until it reaches the final allowed range. The while ((ws > 2 && pw > 1) || ...) computes how many steps are needed.

Cases with more than two steps use ping-pong double buffering:

let readEntry = this.canvasFactory.create(1, 1);
let writeEntry = this.canvasFactory.create(1, 1);
...
for (const { newWidth, newHeight } of scaleSteps) {
  this.canvasFactory.reset(writeEntry, newWidth, newHeight);
  writeEntry.context.drawImage(source, 0, 0, paintWidth, paintHeight,
                               0, 0, newWidth, newHeight);
  [readEntry, writeEntry] = [writeEntry, readEntry];
  ...
}

canvasFactory.reset() means "resize in place and clear", without calling getContext again, saving one context creation. The whole loop uses only two canvases instead of N.

paintImageXObject / paintImageXObjectRepeat

paintImageXObject(opIdx, objId) {
  if (!this.contentVisible) {
    return;
  }
  const imgData = this.getObject(opIdx, objId);
  if (!imgData) {
    warn("Dependent image isn't ready yet");
    return;
  }
  this.paintInlineImageXObject(opIdx, imgData);
}

There is no image decoding here whatsoever.

The worker (article 7's buildPaintImageXObject / _sendImgData) has already handled decoding, color-space conversion, SMask compositing, and the JPEG fast path. What reaches the main thread is an imgData ready to draw:

FieldTypeNotes
width / heightnumberPixel dimensions
dataUint8ClampedArrayRGBA pixels (when not yet decompressed)
kindnumberImageKind.RGBA_32BPP / RGB_24BPP / GRAYSCALE_1BPP
bitmapImageBitmapOptional, an already decoded bitmap
interpolatebooleanWhether smoothing is enabled (PDF's /Interpolate)

paintImageXObjectRepeat optimizes "the same image drawn repeatedly": the worker can compress Do instructions into paintImageXObjectRepeat + position array, so the main thread paints N copies in one call.

The paintInlineImageXObject method

paintInlineImageXObject(opIdx, imgData) {
  if (!this.contentVisible) {
    return;
  }
  const width = imgData.width;
  const height = imgData.height;
  const started = this.#beginKnockoutElement(this.current.fillAlpha);
  const ctx = this.ctx;
 
  this.save(opIdx);
 
  // The filter, if any, will be applied in applyTransferMapsToBitmap.
  // It must be applied to the image before rescaling else some artifacts
  // could appear.
  const { filter } = ctx;
  if (filter !== "none" && filter !== "") {
    ctx.filter = "none";
  }
 
  // scale the image to the unit square
  ctx.scale(1 / width, -1 / height);
 
  let imgToPaint;
  let inlineImgCanvas = null;
  if (imgData.bitmap) {
    const result = this.applyTransferMapsToBitmap(imgData);
    imgToPaint = result.img;
    inlineImgCanvas = result.canvasEntry;
  } else {
    const tmpCanvas = this.canvasFactory.create(width, height);
    putBinaryImageData(tmpCanvas.context, imgData);
    imgToPaint = this.applyTransferMapsToCanvas(tmpCanvas.context);
    inlineImgCanvas = tmpCanvas;
  }
 
  const scaled = this._scaleImage(imgToPaint, getCurrentTransformInverse(ctx));
  ctx.imageSmoothingEnabled = getImageSmoothingEnabled(
    getCurrentTransform(ctx), imgData.interpolate
  );
  ...
  drawImageAtIntegerCoords(
    ctx, scaled.img, 0, 0, scaled.paintWidth, scaled.paintHeight,
    0, -height, width, height
  );
  ...
  this.compose();
  this.restore(opIdx);
  this.#endKnockoutElement(started);
}

The full flow:

  1. Temporarily disable the filter. The current ctx may carry a transfer-function (TR) CSS filter. That filter must be applied at the image's original resolution; applying it after downscaling loses precision. So filter = "none" first, and applyTransferMapsToBitmap handles it on a temporary canvas instead.
  2. Normalize the coordinate system. ctx.scale(1 / width, -1 / height) turns the drawing space into "a unit square with the y axis flipped". PDF image coordinates start at the bottom-left with y pointing up; canvas starts at the top-left with y pointing down. This single scale solves both sizing and flipping, so the subsequent drawImage arguments can uniformly be 0, -height, width, height.
  3. Two drawing paths. If an ImageBitmap exists, use it directly (already decoded by the browser and usable by the GPU); with only raw RGBA data, first putBinaryImageData into a temporary canvas.
  4. imageSmoothingEnabled is decided by /Interpolate. getImageSmoothingEnabled considers both the CTM scale and the PDF flag — PDF by default does no interpolation on images (nearest-neighbor, keeping sharp pixel blocks), but most reading scenarios want smoothing, so PDF.js applies a heuristic here.
  5. compose(). If in soft-mask mode, composite the image back by alpha.

putBinaryImageData is where the FULL_CHUNK_HEIGHT = 16 constant at the top comes in: build ImageData 16 rows at a time and putImageData, reducing per-call overhead and the memory peak for large images.

The paintImageMaskXObject method

paintImageMaskXObject(opIdx, img) {
  const count = img.count;
  img = this.getObject(opIdx, img.data, img);
  img.count = count;
 
  const ctx = this.ctx;
  const mask = this._createMaskCanvas(opIdx, img);
  const maskCanvas = mask.canvas;
 
  ctx.save();
  // The mask is drawn with the transform applied. Reset the current
  // transform to draw to the identity.
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.drawImage(maskCanvas, mask.offsetX, mask.offsetY);
  ...
  ctx.restore();
  ...
  this.compose();
}

An ImageMask is a 1-bit image used as a stencil: each pixel is either "paint the current fill color" or "transparent" (corresponding to PDF's imagemask operator and Type3 glyphs with d1).

The _createMaskCanvas flow expresses exactly this:

fillCtx.globalCompositeOperation = "source-in";
fillCtx.fillStyle = isPatternFill
  ? fillColor.getPattern(...)
  : fillColor;
fillCtx.fillRect(0, 0, width, height);

Draw the mask first, then switch to source-in and flood the fill color over it — source-in keeps only source pixels where the destination (mask) alpha is non-zero, which is exactly "tinting the mask with the fill color". For pattern fills, getPattern produces a Pattern object, allowing characters to be tinted with gradients or tiling patterns.

Two important details:

  1. Caching. img.count > 1 means the same mask is used multiple times (Type3 glyphs, repeated shapes), in which case the tinted result is cached by [first four CTM entries, fillColor]:
cacheKey = JSON.stringify(
  isPatternFill ? currentTransform : [currentTransform.slice(0, 4), fillColor]
);

Only the first four CTM entries (scale + rotation) are used, because translation only affects the clipping of offsetX/offsetY, not the tinted pixels. This optimization pays off hugely for Type3 fonts, where every glyph is the same mask redrawn with a different color and position.

  1. setTransform(1,0,0,1,0,0). The tinted canvas produced by _createMaskCanvas is already "device-pixel aligned", so drawing it must return to the identity matrix and position with offsetX/offsetY — otherwise the transform would be applied twice.

paintSolidColorImageMask is the degenerate case: the mask is all ones, equivalent to "draw a rectangle in the fill color". Because the worker has already pushed the unit-square transform into the CTM before the call, drawing is a single line:

paintSolidColorImageMask(opIdx) {
  if (!this.contentVisible) {
    return;
  }
  const started = this.#beginKnockoutElement(this.current.fillAlpha);
  this.dependencyTracker
    ?.resetBBox(opIdx).recordBBox(opIdx, this.ctx, 0, 1, 0, 1)
    .recordDependencies(opIdx, Dependencies.fill)
    .recordOperation(opIdx);
  this.ctx.fillRect(0, 0, 1, 1);
  this.compose();
  this.#endKnockoutElement(started);
}

No canvas allocation, no decoding, just fillRect(0, 0, 1, 1) — the shortest path in the rendering layer.

Text drawing

Text is the most complex part of the rendering layer: canvas has two fundamentally different painting modes, PDF has four rendering modes (fill, stroke, both, clip), and on top of that come character spacing, pattern fills, and faux bold/italic.

The setFont method

setFont(opIdx, fontRefName, size) {
  const fontObj = this.commonObjs.get(fontRefName);
  const current = this.current;
 
  if (!fontObj) {
    throw new Error(`Can't find font for ${fontRefName}`);
  }
  current.fontMatrix = fontObj.fontMatrix || FONT_IDENTITY_MATRIX;
 
  // A valid matrix needs all main diagonal elements to be non-zero
  if (current.fontMatrix[0] === 0 || current.fontMatrix[3] === 0) {
    warn("Invalid font matrix for font " + fontRefName);
  }
 
  // The spec for Tf (setFont) says that 'size' specifies the font 'scale',
  // and in some docs this can be negative (inverted x-y axes).
  if (size < 0) {
    size = -size;
    current.fontDirection = -1;
  } else {
    current.fontDirection = 1;
  }
 
  this.current.font = fontObj;
  this.current.fontSize = size;
 
  if (fontObj.isType3Font) {
    return; // we don't need ctx.font for Type3 fonts
  }
 
  const name = fontObj.loadedName || "sans-serif";
  const typeface =
    fontObj.systemFontInfo?.css || `"${name}", ${fontObj.fallbackName}`;
  ...
  const browserFontSize = MathClamp(size, MIN_FONT_SIZE, MAX_FONT_SIZE);
  this.current.fontSizeScale = size / browserFontSize;
 
  this.ctx.font = `${italic} ${bold} ${browserFontSize}px ${typeface}`;
}

This maps to the Tf operator. Three key behaviors:

  1. Negative size → direction. PDF allows Tf to take a negative value meaning text is flipped vertically; canvas font does not accept negatives, so the value is made absolute and fontDirection = -1 is recorded, expressed at draw time with ctx.scale(textHScale, fontDirection).
  2. Font size clamping. MathClamp(size, MIN_FONT_SIZE, MAX_FONT_SIZE) limits the actual ctx.font to 16–100px, and the real size is compensated through fontSizeScale in the CTM. The reason is in the constants section (Firefox bug 726227).
  3. Type3 returns immediately. Type3 fonts are not drawn with ctx.font; glyphs come from the operator list, so no font string needs to be set.

Font name priority: systemFontInfo.css (system font — the local font the worker found when useSystemFonts is on) → loadedName (the embedded font injected via @font-face) → fallbackName (fallbacks such as sans-serif).

The showText method

showText(opIdx, glyphs) {
  const current = this.current;
  const font = current.font;
  if (font.isType3Font) {
    this.showType3Text(opIdx, glyphs);
    return;
  }
 
  const fontSize = current.fontSize;
  if (fontSize === 0) {
    return;
  }
  ...
  const fontSizeScale = current.fontSizeScale;
  const charSpacing = current.charSpacing;
  const wordSpacing = current.wordSpacing;
  const fontDirection = current.fontDirection;
  const textHScale = current.textHScale * fontDirection;
  const vertical = font.vertical;
  const spacingDir = vertical ? 1 : -1;
  const widthAdvanceScale = fontSize * current.fontMatrix[0];
 
  const simpleFillText =
    current.textRenderingMode === TextRenderingMode.FILL &&
    !font.disableFontFace &&
    !current.patternFill;
 
  ctx.save();
  if (current.textMatrix) {
    ctx.transform(...current.textMatrix);
  }
  ctx.translate(current.x, current.y + current.textRise);
 
  if (fontDirection > 0) {
    ctx.scale(textHScale, -1);
  } else {
    ctx.scale(textHScale, 1);
  }
  ...
}

This maps to Tj / TJ. Elements of the glyphs array come in two kinds:

  • Numbers: displacements (values in the TJ array, in 1/1000 em units), i.e. "kerning adjustments".
  • Objects: actual glyphs, carrying unicode, width, fontChar, isSpace, operatorListId (Type3).

The three transforms after ctx.save() form the text coordinate system:

CTM × textMatrix × translate(x, y + rise) × scale(textHScale, ±1)

Note the -1 in ctx.scale(textHScale, -1): PDF text space has y pointing up, canvas has it pointing down, so the flip is applied uniformly here. Every glyph's y coordinate afterwards is therefore "up is positive".

The loop handles each glyph:

let x = 0, i;
for (i = 0; i < glyphsLength; ++i) {
  const glyph = glyphs[i];
  if (typeof glyph === "number") {
    x += (spacingDir * glyph * fontSize) / 1000;
    continue;
  }
 
  const spacing = (glyph.isSpace ? wordSpacing : 0) + charSpacing;
  let scaledX = x / fontSizeScale;
  ...
  if (simpleFillText && !accent) {
    ctx.fillText(glyph.fontChar, scaledX, scaledY);   // Fast path
  } else {
    this.paintChar(opIdx, glyph.fontChar, scaledX, scaledY, ...);
  }
 
  const charWidth = vertical
    ? width * widthAdvanceScale - spacing * fontDirection
    : width * widthAdvanceScale + spacing * fontDirection;
  x += charWidth;
}
if (vertical) {
  current.y -= x;
} else {
  current.x += x * textHScale;
}

This does not ctx.translate per glyph; it accumulates into a local x and updates current.x once at the end.

That design matters:

  • Each glyph's position is scaledX = x / fontSizeScale. Dividing by fontSizeScale compensates for the ctx.font size clamp — the canvas coordinate system has already been scaled up by ctx.scale(fontSizeScale, fontSizeScale), but fillText uses the clamped size, so the ratio must be corrected.
  • The loop never touches the ctx transform matrix, only temporarily ctx.scale(characterScaleX, 1) with restore when needed. So each glyph costs one fillText and no matrix multiplication.
  • Only after the loop does current.x += x * textHScale write the advance of the whole string back into the text state for the next Tj.

The advance is glyph.width × fontSize × fontMatrix[0]: glyph.width is the width in 1/1000 em, multiplied by the font size to get the actual dimension, then multiplied by fontMatrix[0] to apply the FontMatrix for fonts where it is not the identity (mostly Type3, though some Type1 fonts too).

simpleFillText is a fast-path flag: only "pure fill + usable font + no pattern" goes through fillText. Everything else falls back to path generation (see paintChar).

The paintChar method

paintChar(opIdx, character, x, y, patternFillTransform, patternStrokeTransform) {
  ...
  let path;
  if (
    (font.disableFontFace || isAddToPathSet || patternFill || patternStroke) &&
    !font.missingFile
  ) {
    path = font.getPathGenerator(this.commonObjs, character);
  }
 
  if (path && (font.disableFontFace || patternFill || patternStroke)) {
    ctx.save();
    ctx.translate(x, y);
    ctx.scale(fontSize, -fontSize);
    ...
    ctx.fill(path);
    ...
    ctx.stroke(path);
    ctx.restore();
  } else {
    ctx.fillText(character, x, y);
    ctx.strokeText(character, x, y);
  }
}

The two painting paths for a single glyph:

PathTriggerImplementation
Font rendering (fast)Normal casectx.fillText / ctx.strokeText, rasterized by the browser's font engine, hitting the glyph cache and sub-pixel anti-aliasing
Path rendering (slow)disableFontFace, pattern fill, text-to-pathfont.getPathGenerator() returns a Path2D, then ctx.fill(path) / ctx.stroke(path)

Under path rendering, ctx.scale(fontSize, -fontSize) maps glyph coordinates (1/1000 em times FontMatrix) to the current font size and flips the y axis. Here fontSize is current.fontSize / current.fontSizeScale — showText has already scaled the canvas by fontSizeScale, so path rendering must use "unclamped real size ÷ scale factor" to stay consistent with the fillText path.

Pattern-filled text is the typical case for path rendering: canvas fillText can only use a solid fillStyle, so to get gradient or tiling patterns on text, it must first become a path. The code stores the pattern coordinate system in patternFillTransform, temporarily setTransforms to it while drawing, then returns:

if (patternFillTransform) {
  currentTransform = ctx.getTransform();
  ctx.setTransform(...patternFillTransform);
  const scaledPath = this.#getScaledPath(path, currentTransform, patternFillTransform);
  ctx.fill(scaledPath);
}

Pattern strokes also need a line-width correction — the pattern matrix's scale would be multiplied into the line width, so the singular values are computed and divided out first:

ctx.lineWidth *= Math.max(XY[0], XY[1]) / fontSize;

The comment admits the limitation: "if sx and sy differ, there is nothing we can do; there will be a rendering bug".

ADD_TO_PATH_FLAG (rendering modes 4/5/6/7) is even more special: text is not painted, only accumulated as a path. paintChar pushes { transform, x, y, fontSize, path } into pendingTextPaths, and endText handles them together:

endText(opIdx) {
  const paths = this.pendingTextPaths;
  const ctx = this.ctx;
  ...
  if (paths !== undefined) {
    const newPath = new Path2D();
    const invTransf = ctx.getTransform().invertSelf();
    for (const { transform, x, y, fontSize, path } of paths) {
      if (!path) {
        continue; // Skip empty paths.
      }
      newPath.addPath(
        path,
        new DOMMatrix(transform)
          .preMultiplySelf(invTransf)
          .translate(x, y)
          .scale(fontSize, -fontSize)
      );
    }
    ctx.clip(newPath);
  }
  delete this.pendingTextPaths;
}

Note preMultiplySelf(invTransf): glyph paths are recorded under the CTM in effect at drawing time, while the CTM at endText may already differ. Multiplying by the inverse of the current CTM converts all those paths into the current coordinate system so they can merge into one clip path.

This is the only implementation of "use text as a clipping path" (Tr 4–Tr 7) in PDF.

Where font weight and italic come from

The ctx.font string only has two dimensions, italic/normal and bold/normal, so how are PDF's /FontWeight 600 and /ItalicAngle -12 expressed?

In two layers:

Layer one: the canvas layer can only degrade to keywords. setFont translates booleans computed by the worker into keywords:

let bold = "normal";
if (fontObj.black) {
  bold = "900";
} else if (fontObj.bold) {
  bold = "bold";
}
const italic = fontObj.italic ? "italic" : "normal";
...
this.ctx.font = `${italic} ${bold} ${browserFontSize}px ${typeface}`;

fontObj.bold / fontObj.black / fontObj.italic are inferred by the worker from the FontDescriptor (fontWeight >= 600 counts as bold, >= 900 as black, italicAngle !== 0 as italic).

Layer two: the real weight and slant angle are declared in @font-face. The worker packs /FontWeight and /ItalicAngle into cssFontInfo, and the main thread registers the font accordingly:

// src/display/font_loader.js
createNativeFontFace() {
  ...
  const css = { weight: this.cssFontInfo.fontWeight };
  if (this.cssFontInfo.italicAngle) {
    css.style = `oblique ${this.cssFontInfo.italicAngle}deg`;
  }
  nativeFontFace = new FontFace(
    serializeFontFamily(this.cssFontInfo.fontFamily), this.data, css
  );
}

The non-FontFace-API path inserts a CSS rule that is equivalent in form:

let css = `font-weight: ${this.cssFontInfo.fontWeight};`;
if (this.cssFontInfo.italicAngle) {
  css += `font-style: oblique ${this.cssFontInfo.italicAngle}deg;`;
}
const rule = `@font-face {font-family:${fontFamily};${css}src:${url}}`;

So "faux italic" is actually produced by the browser's font synthesis (oblique Ndeg is the CSS-standard synthesized slant), not a hand-written skew matrix; the same applies to font-weight, where the browser synthesizes bolding when it cannot find a matching weight.

This also explains another branch in setFont:

const typeface =
  fontObj.systemFontInfo?.css || `"${name}", ${fontObj.fallbackName}`;

systemFontInfo.css is the full CSS font family the worker obtained by probing local fonts (a system font with the correct weight and italic style). On that path the browser can select the genuinely matching system font directly, with no synthesis at all.

There is one probe related to font rendering quality — isFontSubpixelAAEnabled:

// Checks if anti-aliasing is enabled when scaled text is painted.
// On Windows GDI scaled fonts looks bad.
const tmpCanvas = this.canvasFactory.create(10, 10);
const ctx = tmpCanvas.context;
ctx.scale(1.5, 1);
ctx.fillText("I", 0, 10);
const data = ctx.getImageData(0, 0, 10, 10).data;
...
for (let i = 3; i < data.length; i += 4) {
  if (data[i] > 0 && data[i] < 255) { enabled = true; break; }
}

It draws an "I" stretched 1.5× horizontally on a 10×10 temporary canvas and checks for semi-transparent pixels. If any exist, the browser is anti-aliasing. The result is used to correct glyph-width measurements (the width < measuredWidth case), avoiding advance jitter in environments without anti-aliasing. The result is cached by shadow() — probe once, reuse globally.

The showType3Text method

showType3Text(opIdx, glyphs) {
  // Type3 fonts - each glyph is a "mini-PDF"
  const ctx = this.ctx;
  const current = this.current;
  ...
  ctx.save();
  if (current.textMatrix) {
    ctx.transform(...current.textMatrix);
  }
  ctx.translate(current.x, current.y + current.textRise);
  ctx.scale(textHScale, fontDirection);
 
  // Type3 fonts have their own operator list. Avoid mixing it up with the
  // dependency tracker of the main operator list.
  const dependencyTracker = this.dependencyTracker;
  this.dependencyTracker = dependencyTracker
    ? new CanvasNestedDependencyTracker(dependencyTracker, opIdx)
    : null;
 
  for (i = 0; i < glyphsLength; ++i) {
    glyph = glyphs[i];
    if (typeof glyph === "number") { ... continue; }
 
    const spacing = (glyph.isSpace ? wordSpacing : 0) + charSpacing;
    const operatorList = font.charProcOperatorList.get(glyph.operatorListId);
    ...
    if (this.contentVisible) {
      this.save();
      // A d0 (setCharWidth) glyph is colored (see Table 113 in pdf 1.7 specs)
      // unlike a d1 glyph painted as a stencil mask with the current fill
      // color. The constant alphas (ca/CA) are the opacity of that current
      // color, so they must not attenuate a d0 glyph.
      if (operatorList.fnArray[0] === OPS.setCharWidth) {
        current.fillAlpha = current.strokeAlpha = 1;
        ctx.globalAlpha = 1;
      }
      ctx.scale(fontSize, fontSize);
      ctx.transform(...fontMatrix);
      this.executeOperatorList(operatorList);
      this.restore();
    }
    ...
  }
  ctx.restore();
  if (dependencyTracker) {
    this.dependencyTracker = dependencyTracker;
  }
}

The Type3 glyph streams described in article 7 are executed here.

Key points:

  1. font.charProcOperatorList.get(glyph.operatorListId) — each glyph's operator list is compiled ahead of time by the worker's TranslatedFont.loadType3Data and indexed by operatorListId. That means if the same character appears 100 times on a page, the glyph stream is parsed once and the remaining 100 times are pure execution.
  2. The alpha difference between d0 / d1. A d0 (setCharWidth) glyph carries its own color (it can paint a colored glyph), in which case ca/CA must not attenuate it — so fillAlpha / strokeAlpha are forced to 1. A d1 (setCharWidthAndBounds) glyph is a "stencil mask" painted with the current fill color, where alpha applies normally. This is specified in PDF 1.7 Table 113.
  3. Nested dependency tracker. A glyph's own operator list must not pollute the main page's dirty-rectangle/dependency records, so it is temporarily replaced with a CanvasNestedDependencyTracker.
  4. ctx.scale(fontSize, fontSize) + ctx.transform(fontMatrix). Glyph coordinates are in 1/1000 em: first scale by the font size, then apply the font's own FontMatrix.

The setCharWidth / setCharWidthAndBounds methods

setCharWidth(opIdx, xWidth, yWidth) {
  // We can safely ignore this since the width should be the same
  // as the width in the Widths array.
}
 
setCharWidthAndBounds(opIdx, xWidth, yWidth, llx, lly, urx, ury) {
  const clip = new Path2D();
  clip.rect(llx, lly, urx - llx, ury - lly);
  this.ctx.clip(clip);
  ...
}

These are the d0 / d1 operators inside a Type3 glyph stream:

  • d0: declares the glyph width. The width is already in the /Widths array, so it is ignored.
  • d1: declares the width and bounds the glyph box. That rectangle becomes the clip region — glyph content outside the bounds must be clipped (a specification requirement). This is also a performance trap in Type3 rendering: every glyph costs an extra ctx.clip.

Line width and finishing helpers

The getSinglePixelWidth method

getSinglePixelWidth() {
  if (!this._cachedGetSinglePixelWidth) {
    const m = getCurrentTransform(this.ctx);
    if (m[1] === 0 && m[2] === 0) {
      // Fast path
      this._cachedGetSinglePixelWidth =
        1 / Math.min(Math.abs(m[0]), Math.abs(m[3]));
    } else {
      const absDet = Math.abs(m[0] * m[3] - m[2] * m[1]);
      const normX = Math.hypot(m[0], m[2]);
      const normY = Math.hypot(m[1], m[3]);
      this._cachedGetSinglePixelWidth = Math.max(normX, normY) / absDet;
    }
  }
  return this._cachedGetSinglePixelWidth;
}

Why is "single-pixel width" needed? In PDF, w 0 or a very small line width (such as 0.001) logically means "an infinitely thin line", and the spec requires renderers to draw it at the device's minimum visible width — 1 pixel. Scaled by the real CTM, 0.001 would become far less than 1 pixel and nothing would be drawn.

Two computations:

CaseFormulaMeaning
No rotation/skew (m[1] === m[2] === 0)1 / min(abs(a), abs(d))The most conservative direction determines the minimum line width
General casemax(normX, normY) / absDetLarger reciprocal of the two pixel-parallelogram heights

The general formula comes from geometry: a unit pixel is transformed by M into a parallelogram with area |det(M)|, and the two heights are |det(M)| / norm(Mx) and |det(M)| / norm(My). For both heights to be ≥ 1, the line width must take the larger of the two.

The result is cached in _cachedGetSinglePixelWidth; restore and transform reset it to null.

The getScaleForStroking / rescaleAndStroke methods

getScaleForStroking() {
  // A pixel has thicknessX = thicknessY = 1;
  // A transformed pixel is a parallelogram and the thicknesses
  // corresponds to the heights.
  // The goal of this function is to rescale before setting the
  // lineWidth in order to have both thicknesses greater or equal
  // to 1 after transform.
  if (this._cachedScaleForStroking[0] === -1) {
    const { lineWidth } = this.current;
    const { a, b, c, d } = this.ctx.getTransform();
    let scaleX, scaleY;
 
    if (b === 0 && c === 0) {
      // Fast path
      const normX = Math.abs(a);
      const normY = Math.abs(d);
      if (normX === normY) {
        if (lineWidth === 0) {
          scaleX = scaleY = 1 / normX;
        } else {
          const scaledLineWidth = normX * lineWidth;
          scaleX = scaleY = scaledLineWidth < 1 ? 1 / scaledLineWidth : 1;
        }
      } else if (lineWidth === 0) { ... }
      ...
    } else {
      const absDet = Math.abs(a * d - b * c);
      const normX = Math.hypot(a, b);
      const normY = Math.hypot(c, d);
      ...
      const baseArea = lineWidth * absDet;
      scaleX = normY > baseArea ? normY / baseArea : 1;
      scaleY = normX > baseArea ? normX / baseArea : 1;
    }
    this._cachedScaleForStroking[0] = scaleX;
    this._cachedScaleForStroking[1] = scaleY;
  }
  return this._cachedScaleForStroking;
}

Unlike getSinglePixelWidth, this computes a compensation ratio for each direction, because under non-uniform scaling (normX !== normY) the horizontal and vertical line widths need different magnification factors.

The key check is scaledLineWidth < 1: if the line width after transform is already ≥ 1 pixel, no compensation is needed (scale = 1); otherwise it is scaled up to exactly 1 pixel.

rescaleAndStroke uses these two ratios with two branches:

rescaleAndStroke(path, saveRestore) {
  const { ctx, current: { lineWidth } } = this;
  const [scaleX, scaleY] = this.getScaleForStroking();
 
  if (scaleX === scaleY) {
    ctx.lineWidth = (lineWidth || 1) * scaleX;
    ctx.stroke(path);
    return;
  }
  ...
  ctx.scale(scaleX, scaleY);
  SCALE_MATRIX.a = 1 / scaleX;
  SCALE_MATRIX.d = 1 / scaleY;
  const newPath = new Path2D();
  newPath.addPath(path, SCALE_MATRIX);
 
  // How the dashed line is rendered depends on the current transform...
  if (dashes.length > 0) {
    const scale = Math.max(scaleX, scaleY);
    ctx.setLineDash(dashes.map(x => x / scale));
    ctx.lineDashOffset /= scale;
  }
 
  ctx.lineWidth = lineWidth || 1;
  ctx.stroke(newPath);
}
  • Uniform scaling: just change lineWidth; simplest.
  • Non-uniform scaling: a single lineWidth cannot express two directions, so ctx.scale(scaleX, scaleY) enlarges the coordinate system and the path is scaled back with the inverse matrix (SCALE_MATRIX). The line width is then correctly magnified in both directions while the path position stays put.

SCALE_MATRIX is a class-level reused DOMMatrix — this method is called extremely often on complex vector art, and new DOMMatrix() every time would produce a lot of garbage.

Dashes need extra handling: how dashed intervals render depends on the current transform, so after scaling, dashArray and lineDashOffset must be scaled in sync. The comment also admits a residual flaw: "dashes are still buggy when scaleX !== scaleY".

The paintFormXObjectBegin / paintFormXObjectEnd methods

paintFormXObjectBegin(opIdx, matrix, bbox) {
  if (!this.contentVisible) {
    return;
  }
  this.save(opIdx);
  this.baseTransformStack.push(this.baseTransform);
 
  if (matrix) {
    this.transform(opIdx, ...matrix);
  }
  this.baseTransform = getCurrentTransform(this.ctx);
 
  if (bbox) {
    Util.axialAlignedBoundingBox(bbox, this.baseTransform, this.current.minMax);
    const [x0, y0, x1, y1] = bbox;
    const clip = new Path2D();
    clip.rect(x0, y0, x1 - x0, y1 - y0);
    this.ctx.clip(clip);
    this.dependencyTracker?.recordClipBox(opIdx, this.ctx, x0, x1, y0, y1);
    this.endPath(opIdx);
  }
}
 
paintFormXObjectEnd(opIdx) {
  if (!this.contentVisible) {
    return;
  }
  this.restore(opIdx);
  this.baseTransform = this.baseTransformStack.pop();
}

These are the entry and exit of a Form XObject (a reusable content stream referenced by Do).

  • baseTransform goes on a stack. Pattern coordinates inside a Form are based on the Form's Matrix, not the parent CTM, so it must be updated on entry and restored on exit.
  • bbox clipping. /BBox is the Form's boundary, and anything beyond it must be clipped (the specification mandates this). Many PDFs rely on this clip to hide out-of-bounds content.

The beginAnnotation / endAnnotation methods

The entry point for drawing annotations. Unlike ordinary content, annotations are painted after the page content and need clean state:

beginAnnotation(opIdx, id, rect, transform, matrix, hasOwnCanvas, canvasName) {
  // The annotations are drawn just after the page content.
  // The page content drawing can potentially have set a transform,
  // a clipping path, whatever...
  // So in order to have something clean, we restore the initial state.
  this.#restoreInitialState();
  resetCtxToDefault(this.ctx);
 
  this.ctx.save();
  this.save(opIdx);
 
  if (this.baseTransform) {
    this.ctx.setTransform(...this.baseTransform);
  }
  ...
}

#restoreInitialState() empties the entire state stack left behind by the content stream — otherwise the annotation would be polluted by the page's clip path or transform.

When hasOwnCanvas is set, the annotation gets an independent canvas (stored in annotationCanvasMap), so annotations can be repainted/interacted with separately (form widgets, link highlights) without re-rendering the whole page:

const canvasWidth = Math.ceil(width * this.outputScaleX * viewportScale);
const canvasHeight = Math.ceil(height * this.outputScaleY * viewportScale);
...
this.annotationCanvas = this.canvasFactory.create(canvasWidth, canvasHeight);

The size is computed from outputScale (devicePixelRatio) × viewportScale (zoom level), keeping annotations sharp on high-DPI screens.

On exit, ctx is switched back to the main canvas and baseTransform is popped:

endAnnotation(opIdx) {
  if (this.annotationCanvas) {
    this.ctx.restore();
    this.#drawFilter();
 
    this.ctx = this.annotationCanvas.savedCtx;
    delete this.annotationCanvas.savedCtx;
    delete this.annotationCanvas;
  }
  this.baseTransform = this.baseTransformStack.pop();
}

Note that #drawFilter() is called before switching back to the main ctx — the CSS filter generated from the TR transfer function must apply to the annotation's own canvas.

Design note: The complexity of text and images all comes from "the trade-off between two drawing capabilities". fillText is fast but only supports solid colors; path drawing is slow but supports arbitrary patterns. ImageBitmap is fast but only the worker can decode it; ImageData is universal but needs putImageData. Every branch in CanvasGraphics is essentially finding a correct slow path "when the fast path does not apply".

Summary

canvas.js is the endpoint of the PDF.js rendering pipeline and the only module in this series that touches pixels directly.

Where it sits in the pipeline

PDF file
  → PDFManager.initDocument   (Article 6: xref / trailer / page tree)
  → PDFDocument.getPage       (Article 8: page model, Resources)
  → PartialEvaluator          (Article 7: content stream → operatorList)
  → MessageHandler            (Article 4: streaming to the main thread)
  → CanvasGraphics            (This article: operatorList → pixels)

The operatorList CanvasGraphics receives is already a fully evaluated result:

Already done in the workerThe main thread only needs to
Color space → #RRGGBBAssign fillStyle
Font parsing → FontFaceObjectAssign ctx.font
Image decoding → RGBA / ImageBitmapdrawImage
Content stream → Path2D instruction arrayBuild a Path2D and fill it
Mask → pre-baked alphadestination-in

Five core design principles

  1. Operators are methods. The numeric OPS IDs are registered at the end of the file as "ID → same-named method" prototype aliases, and executeOperatorList dispatches with this[fnId](...). Zero switch statements; adding an operator only requires one OPS definition and one method.
  2. An interruptible synchronous loop. Rendering is not an async function but cooperative scheduling: "execute N steps → return the index → get called again externally". The 15ms time slice guarantees main-thread responsiveness, and the returned index guarantees resumability.
  3. Dual state stacks. Canvas-native state and CanvasExtraState are pushed and popped together. Use native state where possible (CTM, line width, color), and track what canvas cannot express (separate fill/stroke alpha, text matrices, Pattern flags) yourself.
  4. All temporary resources go through canvasFactory. Off-screen canvases are heavy resources and are always allocated and returned via the object pool. The ping-pong in _scaleImage, pattern tiles, transparency groups, soft-mask scratch canvases, and annotation canvases are no exception.
  5. Skip whatever can be skipped. contentVisible (layer off), intersect === null (path outside the clip region), canSkipPatternCanvas (single-tile pattern), and the seven-condition fast path in beginGroup — these short circuits are the foundation of PDF.js's performance on real documents.

Series wrap-up

The rendering loop is now complete:

  • Worker side: parse the file (6) → build the document model (8) → evaluate the content stream (7) → produce the operator list.
  • Transport layer: streaming messages (4) + data-fetch strategies (5).
  • Main thread: API and render scheduling (2/3) → execute operators (9).

This is PDF.js's most essential layering: push all CPU-intensive, parallelizable work like "understanding PDF" into the worker; keep work like "painting pixels" that depends on browser capabilities and must be serial on the main thread. Their boundary is the operatorList — a pure-data, serializable, streamable intermediate representation that is almost isomorphic to the canvas API.

StreamPDF's design principle: anything that can be handled ahead of time is never deferred to parse time; the server solves all performance problems once, and the browser only fetches and renders the data it must.

Related articles