Engineering Journal
Pdf Processor
Pdf Processor

Postmortem: Five Wrong Assumptions About PDF.js Image Extraction

2026-05-31

TLDR

Extracting high-resolution PDF raster images inside Web Workers failed across five consecutive implementation attempts due to invalid assumptions regarding PDF.js internal APIs. The pipeline required resolving CanvasFactory option types (passing a class constructor rather than an instance), clustering fragmented paintImageXObject operators, isolating dual rendering scale factors ($2.0\times$ geometry vs. $4.0\times$ image crops), and recording actual crop pixel dimensions alongside Base64 payloads.
Implementation BugInitial False AssumptionEngineering RealityStructural Resolution
1. Factory InstancecanvasFactory accepts object instancePDF.js does new CanvasFactory()Pass CanvasFactory: OffscreenCanvasFactory (Class)
2. Fragmented Operators1 paintImageXObject = 1 visual imageFigures are split into 100+ XObjectsCluster adjacent bboxes within $8\text{px}$ gap
3. Global Scale Limit2.0x global scale produces clear cropsImage crops at 2.0x are blurryRun 4.0x image render pass separately
4. Destination BboxBbox width equals native image widthBbox is page destination rectangleStore native crop pixel dimensions
5. Render Size DriftCrop size can be derived from layoutPDF scale differs from content sizeStore pw/ph crop pixels in state map

Technical defect diagnostics & remediation

1. CanvasFactory option signature

Passing an instance object to getDocument({ canvasFactory }) is silently ignored by PDF.js, triggering fallback calls to DefaultCanvasFactory (document.createElement) and crashing inside Web Workers:
// REFACTORED: Pass class constructor using capital 'C'
export class OffscreenCanvasFactory {
  create(width, height) {
    const canvas = new OffscreenCanvas(width, height);
    return { canvas, context: canvas.getContext('2d') };
  }
  reset(canvasAndCtx, w, h) { canvasAndCtx.canvas.width = w; canvasAndCtx.canvas.height = h; }
  destroy(canvasAndCtx) { canvasAndCtx.canvas.width = 0; canvasAndCtx.canvas.height = 0; }
}

const pdfDoc = await pdfjsLib.getDocument({ data: pdfBytes, CanvasFactory: OffscreenCanvasFactory // Must be class constructor }).promise;


2. Dual scale pipeline execution

Geometry calculations run at $2.0\times$ scale ($192\text{ DPI}$), while image crops execute at $4.0\times$ scale ($384\text{ DPI}$):
export async function executeDualScaleImageCrop(page, imageMetadataList) {
  const GEOMETRY_SCALE = 2.0;
  const IMAGE_SCALE = 4.0;
  const UP_RATIO = IMAGE_SCALE / GEOMETRY_SCALE; // 2.0 multiplier

const imgViewport = page.getViewport({ scale: IMAGE_SCALE }); const pageCanvas = new OffscreenCanvas(imgViewport.width, imgViewport.height);

await page.render({ canvasContext: pageCanvas.getContext('2d'), viewport: imgViewport }).promise;

const extractedMap = {}; for (const meta of imageMetadataList) { const sx = Math.round(meta.bbox.x * UP_RATIO); const sy = Math.round(meta.bbox.y * UP_RATIO); const sw = Math.round(meta.bbox.w * UP_RATIO); const sh = Math.round(meta.bbox.h * UP_RATIO);

const cropCanvas = new OffscreenCanvas(sw, sh); cropCanvas.getContext('2d').drawImage(pageCanvas, sx, sy, sw, sh, 0, 0, sw, sh);

const blob = await cropCanvas.convertToBlob({ type: 'image/png' }); extractedMap[meta.id] = { dataUrl: await blobToDataUrl(blob), pw: sw, // Actual 4x crop pixel width ph: sh // Actual 4x crop pixel height }; } return extractedMap; }

Rule of thumb: Pass CanvasFactory as an uninstantiated class constructor to PDF.js and store actual crop pixel dimensions (pw, ph) alongside Base64 image payloads.
Read this post in the full Engineering Journal →