Engineering Journal
Pdf Processor
Pdf Processor

Under the Hood: Building a Correct PDF Path Reconciler

2026-06-04

TLDR

Transforming raw PDF graphics operator streams into clean, classified vector segments requires a three-stage pipeline: capturing Current Transformation Matrices (CTM) per subpath in user-space (SubpathRecord), computing analytical Bezier curve bounding boxes using derivative roots ($\mathbf{B}'(t) = 0$), and performing global dash partitioning across non-adjacent operator streams using multi-key grouping (strokeColor, strokeWidth, orientation, and $Y$-band).
Pipeline StageTechnical ResponsibilityCoordinate SpaceCritical Output
1. CTM AdapterSubpath parsing + CTM captureNative PDF User-SpaceSubpathRecord arrays
2. Path ReconcilerAnalytical Bezier & Thin RectsViewport Canvas SpaceCanonical H/V segments
3. Global PartitionNon-adjacent dash mergingViewport Canvas SpaceMerged dash rules

Technical architecture & implementation

1. Subpath record capture (user-space CTM storage)

Defer viewport coordinate transforms until after subpath classification is completed:
export function openSubpathRecord(ctmState, strokeWidth, strokeColor, fillColor, constructPathId) {
  return {
    segs: [],
    curves: [],
    closed: false,
    filled: false,
    strokeWidth,
    strokeColor: strokeColor.slice(),
    fillColor: fillColor.slice(),
    constructPathId,
    ctm: ctmState.slice(), // Capture matrix state per subpath
    id: generateSubpathId()
  };
}

2. Analytical Bezier curve bounding box solver

Calculate exact Bezier curve bounds by solving derivative roots analytically:
// Solve roots of quadratic derivative B'(t) = 0 for cubic Beziers
export function computeCubicBezierBbox(p0, p1, p2, p3) {
  const findExtremaRoots = (a, b, c, d) => {
    const dA = -3  a + 9  b - 9  c + 3  d;
    const dB = 6  a - 12  b + 6 * c;
    const dC = -3  a + 3  b;
    const roots = [];

if (Math.abs(dA) > 1e-6) { const discriminant = dB dB - 4 dA * dC; if (discriminant >= 0) { const sqrtDisc = Math.sqrt(discriminant); const t1 = (-dB + sqrtDisc) / (2 * dA); const t2 = (-dB - sqrtDisc) / (2 * dA); if (t1 > 0 && t1 < 1) roots.push(t1); if (t2 > 0 && t2 < 1) roots.push(t2); } } else if (Math.abs(dB) > 1e-6) { roots.push(-dC / dB); }

const evaluateBezier = t => a Math.pow(1 - t, 3) + 3 b t Math.pow(1 - t, 2) + 3 c Math.pow(t, 2) (1 - t) + d Math.pow(t, 3); return [a, d, ...roots.map(evaluateBezier)]; };

const xs = findExtremaRoots(p0[0], p1[0], p2[0], p3[0]); const ys = findExtremaRoots(p0[1], p1[1], p2[1], p3[1]);

return { xMin: Math.min(...xs), xMax: Math.max(...xs), yMin: Math.min(...ys), yMax: Math.max(...ys) }; }


3. Global dash partitioning

Partition non-adjacent single-segment subpaths using multi-key grouping:
export function mergeGlobalDashPartitions(classifiedSubpaths, epsilonPx = 4) {
  const partitions = new Map();

for (const item of classifiedSubpaths) { if (item.type !== 'FREE_PATH' || item.segsViewport.length !== 1) continue;

const segment = item.segsViewport[0]; const colorHex = item.strokeColor.map(c => Math.round(c * 255).toString(16).padStart(2, '0')).join(''); const strokeWidthBucket = Math.round(item.strokeWidth * 2); const orientation = Math.abs(segment.ay - segment.by) < epsilonPx ? 'H' : 'V'; const yBucket = Math.round(segment.ay / epsilonPx) * epsilonPx;

const key = ${colorHex}|${strokeWidthBucket}|${orientation}|${yBucket}; if (!partitions.has(key)) partitions.set(key, []); partitions.get(key).push({ item, segment }); }

// Merge gaps within partitions smaller than max(8px, 0.4 * avgDashLength) return emitMergedDashSegments(partitions); }

Rule of thumb: Capture CTM state per subpath in user-space, solve Bezier bounds analytically, and group dashes by multi-key global partitions.
Read this post in the full Engineering Journal →