Engineering Journal
Pdf Processor
Pdf Processor

Coordinate Spaces Are Not Optional: A Silent Bug in PDF Extraction

2026-05-30

TLDR

PDF parsing SDKs (like pdfjs-dist) emit text element coordinates in PDF user space (points: 1/72 inch) while canvas rendering viewports operate in screen pixels. Adding an untransformed text width (points) to a transformed viewport coordinate (pixels) creates a $33\%$ measurement error at $1.5\times$ scale. Storing explicit viewport-scaled values (vWidth, vFont) alongside original PDF point values (width, fontSize) on every record resolves coordinate mismatch defects permanently.
Coordinate DomainOriginMeasurement UnitApplied Transformation
PDF User SpaceBottom-LeftPDF Points ($1/72\text{ in}$)None (Raw PDF stream values)
Viewport Screen SpaceTop-LeftScreen Pixelsviewport.transform ($1.5\times$ scale)

Problem statement: the mixed-unit addition trap

PDF.js exposes text item positions via item.transform[4] and item.transform[5] in PDF user space, while item.width is reported in PDF points.

When rendering at scale $1.5\times$, the transform matrix inverts the Y axis and scales coordinates:

$$\text{viewport.transform} = [1.5, 0, 0, -1.5, 0, \text{height}]$$

Our underline detection heuristic transformed text positions to viewport space (vx), but added untransformed item.width (PDF points):

// DEFECTIVE IMPLEMENTATION: Mixing viewport pixels with PDF points
const [vx, vy] = convertToViewportPoint(transformMatrix, item.transform[4], item.transform[5]);
const textXEnd = vx + item.width; // FAILS: vx is screen pixels, item.width is PDF points!

At scale $1.5\times$, item.width was $33\%$ narrower than the text's actual visual footprint in screen pixels, causing underline detectors and column coverage maps to miscalculate overlap.


Technical failure modes

  1. Underline Detection Escape: Underline detectors evaluated text bounds as $33\%$ shorter than actual rendered lines, missing valid vector underlines.
  2. Distorted Column Coverage: Spatial hash grid arrays indexed by screen pixels received PDF point widths, distorting gutter valley calculations.
  3. Y-Band Tolerance Skew: Paragraph line grouping thresholds (fontSize * 0.45) compared PDF point font sizes against screen pixel vertical gaps.

The fix: dual coordinate property normalization

Derive effective scale factors from the viewport matrix and maintain both units explicitly on every metadata record:

export function normalizeTextItemCoordinates(textItems, viewport) {
  const vpTransform = viewport.transform;

// Derive explicit scale factors for X and Y axes const scaleX = Math.hypot(vpTransform[0], vpTransform[1]) || 1; const scaleY = Math.hypot(vpTransform[2], vpTransform[3]) || 1;

return textItems.map((item, idx) => { const [vx, vy] = convertToViewportPoint(vpTransform, item.transform[4], item.transform[5]); const fontSizePt = Math.abs(item.transform[3] || 12); const widthPt = item.width || (fontSizePt 0.5 (item.str.length || 1));

return { idx, // Viewport Screen Space (Pixels) for spatial proximity & intersection math vx, vy, vWidth: widthPt * scaleX, vFont: fontSizePt * scaleY,

// PDF User Space (Points) for unitless font size ratios fontSize: fontSizePt, width: widthPt }; }); }

Rule of thumb: Never add values from different coordinate domains in a single arithmetic expression. Store explicit viewport-scaled values (vWidth, vFont) alongside original PDF point properties.
Read this post in the full Engineering Journal →