2. Coordinate System Translation
Canvas libraries and PDF documents use fundamentally different coordinate systems. Canvas origins sit at the top-left with Y increasing downward. PDF origins sit at the bottom-left with Y increasing upward. Additionally, canvas units are pixels, while PDF units are points (1 inch = 72 points).
A robust conversion layer must handle both unit scaling and axis inversion:
class CoordinateMapper {
private readonly POINTS_PER_INCH = 72;
private readonly TARGET_DPI = 300;
public toPdfUnits(canvasX: number, canvasY: number, canvasHeight: number): [number, number] {
const scaleFactor = this.POINTS_PER_INCH / this.TARGET_DPI;
const pdfX = canvasX * scaleFactor;
const pdfY = (canvasHeight - canvasY) * scaleFactor;
return [pdfX, pdfY];
}
}
This mapper ensures every element aligns correctly regardless of canvas dimensions. The Y-axis inversion is applied consistently across all object types, preventing baseline drift in text and misaligned group boundaries.
PDF graphics state relies on a Current Transformation Matrix (CTM) to handle scaling, rotation, and translation. Since standard PDF libraries do not expose low-level CTM manipulation, the renderer must maintain a parallel stack:
type Matrix3x2 = [number, number, number, number, number, number];
class TransformTracker {
private activeMatrix: Matrix3x2 = [1, 0, 0, 1, 0, 0];
private history: Matrix3x2[] = [];
public pushState(): void {
this.history.push([...this.activeMatrix]);
}
public popState(): void {
const restored = this.history.pop();
if (restored) this.activeMatrix = restored;
}
public applyTransform(scaleX: number, scaleY: number, rotateRad: number, tx: number, ty: number): void {
const cos = Math.cos(rotateRad);
const sin = Math.sin(rotateRad);
const rotation: Matrix3x2 = [cos * scaleX, sin * scaleX, -sin * scaleY, cos * scaleY, 0, 0];
const translation: Matrix3x2 = [1, 0, 0, 1, tx, ty];
this.activeMatrix = this.multiplyMatrices(
this.multiplyMatrices(this.activeMatrix, rotation),
translation
);
}
private multiplyMatrices(a: Matrix3x2, b: Matrix3x2): Matrix3x2 {
return [
a[0] * b[0] + a[2] * b[1],
a[1] * b[0] + a[3] * b[1],
a[0] * b[2] + a[2] * b[3],
a[1] * b[2] + a[3] * b[3],
a[0] * b[4] + a[2] * b[5] + a[4],
a[1] * b[4] + a[3] * b[5] + a[5]
];
}
}
The stack-based approach guarantees that nested groups, rotated containers, and SVG fragments maintain correct spatial relationships. Every time the renderer enters a group, it pushes the current matrix. Upon exit, it pops back to the parent state. This prevents cumulative transform drift, a common failure point in naive implementations.
4. SVG Path to PDF Operator Translation
Canvas libraries typically represent complex shapes as SVG path strings. PDF uses a different operator set. The renderer must parse SVG commands and emit equivalent PDF drawing instructions:
const SVG_TO_PDF_MAP: Record<string, string> = {
M: 'm', m: 'm',
L: 'l', l: 'l',
H: 'l', V: 'l',
C: 'c', c: 'c',
S: 'c',
Z: 'h', z: 'h'
};
class PathTranslator {
public convertToPdfOperators(svgPath: string): string {
const commands = this.tokenize(svgPath);
const pdfOps: string[] = [];
for (const cmd of commands) {
const pdfOp = SVG_TO_PDF_MAP[cmd.type];
if (!pdfOp) continue;
if (cmd.type === 'A' || cmd.type === 'a') {
pdfOps.push(...this.approximateArcAsCubic(cmd.params));
} else {
pdfOps.push(`${cmd.params.join(' ')} ${pdfOp}`);
}
}
return pdfOps.join('\n');
}
private approximateArcAsCubic(params: number[]): string[] {
// Elliptical arc parameters: rx, ry, x-axis-rotation, large-arc-flag, sweep-flag, x, y
const [rx, ry, rotation, largeArc, sweep, endX, endY] = params;
// Decompose arc into cubic Bezier segments using standard geometric approximation
// Returns array of "cx1 cy1 cx2 cy2 x y c" strings
return this.generateCubicSegments(rx, ry, rotation, largeArc, sweep, endX, endY);
}
}
The elliptical arc (A/a) requires special handling because PDF lacks a direct equivalent. The standard approach decomposes the arc into multiple cubic Bezier curves, ensuring visual fidelity while remaining compatible with the PDF rendering engine.
5. Text Rendering and Font Subsetting
Text export presents a trade-off between editability and visual consistency. Embedding fonts preserves selectability but increases file size. Converting text to vector paths guarantees identical rendering across all devices but sacrifices searchability.
A production pipeline should support both modes, with font subsetting as the default for embedded text:
class TextRenderer {
public async renderText(
content: string,
fontBuffer: ArrayBuffer,
mode: 'embedded' | 'outlined',
fontSize: number
): Promise<PdfDrawInstruction> {
if (mode === 'outlined') {
const glyphPaths = this.extractGlyphOutlines(content, fontBuffer, fontSize);
return { type: 'path', data: glyphPaths };
}
const subsetBuffer = await this.subsetFont(fontBuffer, content);
return { type: 'font', data: subsetBuffer, size: fontSize };
}
private async subsetFont(fullBuffer: ArrayBuffer, usedText: string): Promise<ArrayBuffer> {
// Parse font tables, identify used glyph IDs, strip unused tables
// Return minimized font binary compatible with PDF embedding
return this.runSubsetAlgorithm(fullBuffer, usedText);
}
}
Font subsetting reduces embedded font size by 60β90%, particularly for CJK or symbol-heavy typefaces. The renderer tracks every glyph used during traversal, builds a minimal glyph table, and serializes only the required outlines. This keeps vector PDFs lean without compromising typographic accuracy.
Pitfall Guide
1. Coordinate System Assumption
Explanation: Assuming canvas and PDF share the same origin and axis direction causes vertical mirroring and baseline misalignment.
Fix: Always apply Y-axis inversion and unit scaling at the point of coordinate extraction. Never pass raw canvas coordinates directly to PDF drawing functions.
2. CTM Stack Desynchronization
Explanation: Forgetting to pop the transformation matrix after rendering a group causes subsequent elements to inherit stale transforms, resulting in skewed layouts.
Fix: Wrap every group traversal in a strict push/pop block. Use try/finally to guarantee stack restoration even if rendering fails.
3. Naive Arc Conversion
Explanation: Attempting to map SVG elliptical arcs directly to PDF line operators produces jagged, inaccurate curves.
Fix: Implement a cubic Bezier decomposition algorithm. Split arcs into segments no larger than 90 degrees to maintain mathematical precision.
4. Full Font Embedding
Explanation: Embedding entire font files for short text blocks bloats PDF size and slows serialization.
Fix: Implement glyph tracking during traversal. Only serialize the subset of glyphs actually used in the document.
5. Stroke/Fill Order Reversal
Explanation: Drawing fills before strokes causes stroke edges to be clipped or partially obscured, especially with thick borders.
Fix: Always emit stroke operators before fill operators. For outlined text, draw the stroke path first, then overlay the fill path.
6. Main Thread Blocking
Explanation: Running path parsing, font processing, and PDF serialization on the UI thread causes frame drops and unresponsive interfaces.
Fix: Offload the entire export pipeline to a Web Worker. Use structured cloning for data transfer and postMessage for progress updates.
7. Color Space Mismatch
Explanation: Assuming all exports use RGB causes CMYK print workflows to fail. PDF requires explicit color space declaration for each drawing operation.
Fix: Maintain a color space context during rendering. Convert RGB values to CMYK when the export mode specifies print compliance, and declare the appropriate PDF color space operator.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal draft review | Raster embedding | Fast export, acceptable quality for screen viewing | Low CPU, high storage |
| Client presentation | Vector with embedded fonts | Selectable text, scalable graphics, moderate file size | Moderate CPU, low storage |
| Professional print | Vector with CMYK + font subsetting | Prepress compliance, infinite scalability, minimal bloat | High CPU, minimal storage |
| Template distribution | Vector with text-to-path | Guaranteed visual consistency across all devices | High CPU, moderate storage |
Configuration Template
interface ExportPipelineConfig {
mode: 'raster' | 'vector';
colorSpace: 'rgb' | 'cmyk';
textStrategy: 'embedded' | 'outlined';
dpi: number;
workerPath: string;
progressCallback: (percent: number, stage: string) => void;
}
const defaultConfig: ExportPipelineConfig = {
mode: 'vector',
colorSpace: 'rgb',
textStrategy: 'embedded',
dpi: 300,
workerPath: '/workers/pdf-generator.js',
progressCallback: () => {}
};
export function initializeExport(config: Partial<ExportPipelineConfig>) {
const settings = { ...defaultConfig, ...config };
const worker = new Worker(settings.workerPath);
worker.onmessage = (e) => {
if (e.data.type === 'progress') {
settings.progressCallback(e.data.percent, e.data.stage);
} else if (e.data.type === 'complete') {
const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'export.pdf';
a.click();
URL.revokeObjectURL(url);
}
};
return worker;
}
Quick Start Guide
- Initialize the Worker: Create a dedicated Web Worker file that imports your PDF serialization library and canvas traversal logic.
- Serialize Canvas State: Extract object properties (type, coordinates, transforms, styles) from your canvas library and send them as a structured JSON payload to the worker.
- Run the Renderer: Inside the worker, instantiate the coordinate mapper, CTM tracker, and path translator. Traverse the payload and emit PDF drawing commands.
- Handle Progress & Completion: Listen for chunked progress messages to update the UI. Upon completion, receive the PDF byte array, construct a Blob, and trigger the download.
- Validate Output: Open the generated PDF in a professional viewer. Verify text selectability, zoom scalability, color accuracy, and file size against your target metrics.