Coordinate Spaces Are Not Optional: A Silent Bug in PDF Extraction
TLDR
PDF parsing SDKs (likepdfjs-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 Domain | Origin | Measurement Unit | Applied Transformation |
|---|---|---|---|
| PDF User Space | Bottom-Left | PDF Points ($1/72\text{ in}$) | None (Raw PDF stream values) |
| Viewport Screen Space | Top-Left | Screen Pixels | viewport.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
- Underline Detection Escape: Underline detectors evaluated text bounds as $33\%$ shorter than actual rendered lines, missing valid vector underlines.
- Distorted Column Coverage: Spatial hash grid arrays indexed by screen pixels received PDF point widths, distorting gutter valley calculations.
- 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.