Under the Hood: Extracting Images from a PDF in a Web Worker
TLDR
PDF.js does not expose a synchronousgetImages() 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 Step | Geometry Processing | Image Crop Extraction |
|---|---|---|
| Viewport Scale Factor | $\text{Scale } 2.0$ ($192\text{ DPI}$) | $\text{Scale } 4.0$ ($384\text{ DPI}$) |
| Canvas Factory Implementation | OffscreenCanvasFactory | OffscreenCanvasFactory |
| 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 expectsCanvasFactory 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 ofpaintImageXObject 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.