Under the Hood: Building a Correct PDF Path Reconciler
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 Stage | Technical Responsibility | Coordinate Space | Critical Output |
|---|---|---|---|
| 1. CTM Adapter | Subpath parsing + CTM capture | Native PDF User-Space | SubpathRecord arrays |
| 2. Path Reconciler | Analytical Bezier & Thin Rects | Viewport Canvas Space | Canonical H/V segments |
| 3. Global Partition | Non-adjacent dash merging | Viewport Canvas Space | Merged 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.