Postmortem: Five Wrong Assumptions About PDF.js Image Extraction
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 resolvingCanvasFactory 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 Bug | Initial False Assumption | Engineering Reality | Structural Resolution |
|---|---|---|---|
| 1. Factory Instance | canvasFactory accepts object instance | PDF.js does new CanvasFactory() | Pass CanvasFactory: OffscreenCanvasFactory (Class) |
| 2. Fragmented Operators | 1 paintImageXObject = 1 visual image | Figures are split into 100+ XObjects | Cluster adjacent bboxes within $8\text{px}$ gap |
| 3. Global Scale Limit | 2.0x global scale produces clear crops | Image crops at 2.0x are blurry | Run 4.0x image render pass separately |
| 4. Destination Bbox | Bbox width equals native image width | Bbox is page destination rectangle | Store native crop pixel dimensions |
| 5. Render Size Drift | Crop size can be derived from layout | PDF scale differs from content size | Store pw/ph crop pixels in state map |
Technical defect diagnostics & remediation
1. CanvasFactory option signature
Passing an instance object togetDocument({ 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: PassCanvasFactoryas an uninstantiated class constructor to PDF.js and store actual crop pixel dimensions (pw,ph) alongside Base64 image payloads.