Engineering Journal
Pdf Processor
Pdf Processor

Under the Hood: Extracting Images from a PDF in a Web Worker

2026-06-04

TLDR

PDF.js does not expose a synchronous getImages() Blob API because PDF image XObjects are paint commands evaluated during rendering. Extracting raster images inside a Web Worker requires replacing DOMCanvasFactory with a custom OffscreenCanvasFactory class constructor, executing a high-resolution render pass ($384\text{ DPI}, \text{Scale } 4.0$), and cropping image bounding boxes using CTM coordinate scaling ratios ($\text{Scale } 4.0 / \text{Scale } 2.0 = 2.0$).
Execution StepGeometry ProcessingImage Crop Extraction
Viewport Scale Factor$\text{Scale } 2.0$ ($192\text{ DPI}$)$\text{Scale } 4.0$ ($384\text{ DPI}$)
Canvas Factory ImplementationOffscreenCanvasFactoryOffscreenCanvasFactory
Coordinate Transformation Ratio$1.0\times$ (Standard Viewport)$2.0\times$ ($\text{Scale } 4.0 / \text{Scale } 2.0$)

Technical architecture & implementation

1. OffscreenCanvasFactory Web worker constructor

PDF.js expects CanvasFactory as a class constructor function:
export class OffscreenCanvasFactory {
  create(width, height) {
    const canvas = new OffscreenCanvas(width, height);
    return { canvas, context: canvas.getContext('2d') };
  }

reset(canvasAndCtx, width, height) { canvasAndCtx.canvas.width = width; canvasAndCtx.canvas.height = height; }

destroy(canvasAndCtx) { canvasAndCtx.canvas.width = 0; canvasAndCtx.canvas.height = 0; canvasAndCtx.canvas = null; canvasAndCtx.context = null; } }

// Pass constructor class to getDocument options const pdfDocument = await pdfjsLib.getDocument({ data: pdfBytes, CanvasFactory: OffscreenCanvasFactory // Note capital 'C' }).promise;


2. Dual-Scale crop architecture

To prevent fuzzy CSS stretching, crop images from a dedicated $4.0\times$ scale render pass while geometry logic runs at $2.0\times$ scale:
export async function cropPdfImages(page, imageMetadataList) {
  const RENDER_SCALE = 4.0;
  const UP_RATIO = RENDER_SCALE / 2.0; // 2.0 ratio converts 2x geometry coords to 4x pixels

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

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

const extractedImagesMap = {};

for (const meta of imageMetadataList) { const { x, y, w, h } = meta.bbox;

// Scale coordinates to 4x pixel dimensions const sx = Math.max(0, Math.round(x * UP_RATIO)); const sy = Math.max(0, Math.round(y * UP_RATIO)); const sw = Math.min(Math.round(w * UP_RATIO), pageCanvas.width - sx); const sh = Math.min(Math.round(h * UP_RATIO), pageCanvas.height - sy);

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' }); const arrayBuffer = await blob.arrayBuffer();

extractedImagesMap[meta.id] = { dataUrl: data:image/png;base64,${arrayBufferToBase64(arrayBuffer)}, pixelWidth: sw, pixelHeight: sh }; }

return extractedImagesMap; }


3. Image fragment clustering

PDF generators frequently emit hundreds of paintImageXObject commands for a single visual chart. Cluster adjacent image bounding boxes within $8\text{px}$ before extraction:
export function clusterAdjacentImageMetadata(imageMetaList, gapThresholdPx = 8) {
  const sorted = [...imageMetaList].sort((a, b) => a.bbox.y - b.bbox.y);
  const clusters = [];

for (const item of sorted) { const { x, y, w, h } = item.bbox; const right = x + w; const bottom = y + h;

const matchingCluster = clusters.find(c => x <= c.right + gapThresholdPx && right >= c.x - gapThresholdPx && y <= c.bottom + gapThresholdPx && bottom >= c.y - gapThresholdPx );

if (matchingCluster) { matchingCluster.x = Math.min(matchingCluster.x, x); matchingCluster.y = Math.min(matchingCluster.y, y); matchingCluster.right = Math.max(matchingCluster.right, right); matchingCluster.bottom = Math.max(matchingCluster.bottom, bottom); } else { clusters.push({ id: item.id, x, y, right, bottom }); } }

return clusters.map(c => ({ id: c.id, bbox: { x: c.x, y: c.y, w: c.right - c.x, h: c.bottom - c.y } })); }

Rule of thumb: Pass OffscreenCanvasFactory as a class constructor to PDF.js and crop images from a $4.0\times$ scale canvas render pass inside Web Workers.
Read this post in the full Engineering Journal →