Skip to content

Commit 47d206d

Browse files
authored
feat(svg): add SVG path parsing and rendering (#6)
Add support for drawing SVG path data onto PDF pages. This enables rendering icons, logos, and vector graphics from SVG files. - Parse all SVG path commands (M/L/H/V/C/S/Q/T/A/Z and relative) - Convert arcs to bezier curves for PDF compatibility - Add PathBuilder.appendSvgPath() and PDFPage.drawSvgPath() - Auto-transform Y-axis so SVG coords render correctly in PDF - Include comprehensive tests and three usage examples
1 parent 03919ab commit 47d206d

22 files changed

+5967
-40
lines changed
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
# SVG Path Support
2+
3+
## Problem Statement
4+
5+
Users want to draw SVG path data onto PDF pages. A common use case is sewing patterns and technical drawings stored as SVG that need to be rendered to PDF.
6+
7+
**User request from Reddit:**
8+
9+
> I am currently using both PDFKit and pdfjs to create printable sewing patterns from SVG data. I currently have to take all my SVG path data, put it into an A0 PDF, load that PDF into a canvas element, then chop up the canvas image data into US letter sizes.
10+
11+
## Goals
12+
13+
1. Parse SVG path `d` attribute strings and render them via `PathBuilder`
14+
2. Support all SVG path commands (M, L, H, V, C, S, Q, T, A, Z) in both absolute and relative forms
15+
3. Integrate cleanly with existing `PathBuilder` API
16+
4. Flip the Y-axis by default so raw SVG coordinates map correctly into PDF space (with an opt-out)
17+
18+
## Non-Goals
19+
20+
- Full SVG document parsing (elements like `<text>`, `<image>`, `<use>`, CSS, filters)
21+
- SVG transforms (users can apply PDF transforms separately)
22+
- SVG units conversion (assume unitless = points, like the rest of our API)
23+
- Page tiling/splitting (users handle this themselves with our primitives)
24+
25+
## Scope
26+
27+
**In scope:**
28+
29+
- SVG path `d` string parser
30+
- All path commands: M, m, L, l, H, h, V, v, C, c, S, s, Q, q, T, t, A, a, Z, z
31+
- Arc-to-bezier conversion for the `A` command
32+
- `PathBuilder.appendSvgPath()` instance method
33+
- `PDFPage.drawSvgPath()` convenience method
34+
35+
**Out of scope:**
36+
37+
- Helper to extract paths from SVG documents (maybe later as a separate utility)
38+
- Viewbox/coordinate system transforms
39+
- Stroke/fill style parsing from SVG attributes
40+
41+
---
42+
43+
## Desired Usage
44+
45+
### Basic: Draw SVG path data
46+
47+
```typescript
48+
// Convenience method on PDFPage - fill by default
49+
page.drawSvgPath("M 10 10 L 100 10 L 100 100 Z", {
50+
color: rgb(1, 0, 0),
51+
});
52+
53+
// With stroke
54+
page.drawSvgPath("M 10 10 C 20 20, 40 20, 50 10", {
55+
borderColor: rgb(0, 0, 0),
56+
borderWidth: 2,
57+
});
58+
```
59+
60+
### Using PathBuilder for more control
61+
62+
```typescript
63+
// When you need to choose fill vs stroke explicitly
64+
page
65+
.drawPath()
66+
.appendSvgPath("M 10 10 L 100 10 L 100 100 Z")
67+
.stroke({ borderColor: rgb(0, 0, 0) });
68+
```
69+
70+
### Chaining with existing PathBuilder methods
71+
72+
```typescript
73+
page
74+
.drawPath()
75+
.moveTo(0, 0)
76+
.appendSvgPath("l 50 50 c 10 10 20 20 30 10") // relative commands continue from current point
77+
.lineTo(200, 200)
78+
.close()
79+
.stroke();
80+
```
81+
82+
### Complex paths (sewing patterns, icons)
83+
84+
```typescript
85+
// Heart shape
86+
page
87+
.drawPath()
88+
.appendSvgPath("M 10,30 A 20,20 0,0,1 50,30 A 20,20 0,0,1 90,30 Q 90,60 50,90 Q 10,60 10,30 Z")
89+
.fill({ color: rgb(1, 0, 0) });
90+
91+
// Multiple subpaths
92+
page
93+
.drawPath()
94+
.appendSvgPath("M 0 0 L 100 0 L 100 100 L 0 100 Z M 25 25 L 75 25 L 75 75 L 25 75 Z")
95+
.fill({ windingRule: "evenodd" }); // Creates a square with a square hole
96+
```
97+
98+
---
99+
100+
## Architecture
101+
102+
```
103+
┌─────────────────────────────────────────────────────────────┐
104+
│ PathBuilder.fromSvgPath() / .appendSvgPath() │
105+
│ (Entry points - high-level API) │
106+
├─────────────────────────────────────────────────────────────┤
107+
│ src/svg/path-parser.ts │
108+
│ (Parse d string → command objects) │
109+
├─────────────────────────────────────────────────────────────┤
110+
│ src/svg/path-executor.ts │
111+
│ (Execute commands via callback interface) │
112+
│ - Handles relative → absolute conversion │
113+
│ - Handles smooth curve reflection │
114+
│ - Handles arc → bezier conversion │
115+
├─────────────────────────────────────────────────────────────┤
116+
│ PathBuilder (existing) │
117+
│ (moveTo, lineTo, curveTo, quadraticCurveTo, close) │
118+
└─────────────────────────────────────────────────────────────┘
119+
```
120+
121+
The `src/svg/` module is intentionally decoupled from `PathBuilder`. The executor takes a callback interface, so it can drive any path-building target (PathBuilder, canvas, testing, etc.).
122+
123+
### New Files
124+
125+
| File | Purpose |
126+
| -------------------------- | ---------------------------------------------------------------------------------- |
127+
| `src/svg/path-parser.ts` | Tokenize and parse SVG path `d` strings into command objects |
128+
| `src/svg/path-executor.ts` | Execute parsed commands with state tracking (relative coords, smooth curves, arcs) |
129+
| `src/svg/arc-to-bezier.ts` | Arc endpoint → center parameterization and bezier approximation |
130+
| `src/svg/index.ts` | Public exports |
131+
132+
### Modified Files
133+
134+
| File | Changes |
135+
| --------------------------------- | --------------------------------------- |
136+
| `src/api/drawing/path-builder.ts` | Add `appendSvgPath()` instance method |
137+
| `src/api/pdf-page.ts` | Add `drawSvgPath()` convenience method |
138+
| `src/index.ts` | Export svg utilities for advanced users |
139+
140+
---
141+
142+
## SVG Path Command Reference
143+
144+
| Command | Parameters | Description | PathBuilder equivalent |
145+
| ------- | ------------------------------- | ---------------- | --------------------------------------------- |
146+
| M/m | x y | Move to | `moveTo(x, y)` |
147+
| L/l | x y | Line to | `lineTo(x, y)` |
148+
| H/h | x | Horizontal line | `lineTo(x, currentY)` |
149+
| V/v | y | Vertical line | `lineTo(currentX, y)` |
150+
| C/c | x1 y1 x2 y2 x y | Cubic bezier | `curveTo(...)` |
151+
| S/s | x2 y2 x y | Smooth cubic | Reflect last CP, then `curveTo(...)` |
152+
| Q/q | x1 y1 x y | Quadratic bezier | `quadraticCurveTo(...)` |
153+
| T/t | x y | Smooth quadratic | Reflect last CP, then `quadraticCurveTo(...)` |
154+
| A/a | rx ry angle large-arc sweep x y | Elliptical arc | Convert to bezier curves |
155+
| Z/z | (none) | Close path | `close()` |
156+
157+
**Lowercase = relative coordinates** (offset from current point)
158+
**Uppercase = absolute coordinates**
159+
160+
---
161+
162+
## Test Plan
163+
164+
### Unit Tests
165+
166+
**Parser tests (`src/svg/path-parser.test.ts`):**
167+
168+
- Basic commands: M, L, H, V, C, Q, Z
169+
- Relative commands: m, l, h, v, c, q, z
170+
- Smooth curves: S, s, T, t
171+
- Arcs: A, a (various flag combinations)
172+
- Number formats: integers, decimals, negative, scientific notation
173+
- Whitespace variations: spaces, commas, no separators
174+
- Repeated commands (implicit repetition)
175+
- Invalid input handling (malformed paths)
176+
177+
**Executor tests (`src/svg/path-executor.test.ts`):**
178+
179+
- Relative to absolute conversion
180+
- Smooth curve control point reflection
181+
- Arc to bezier conversion accuracy
182+
- State tracking across commands
183+
184+
**Arc conversion tests (`src/svg/arc-to-bezier.test.ts`):**
185+
186+
- Various arc flag combinations (large-arc, sweep)
187+
- Degenerate cases (zero radii, same start/end point)
188+
- Accuracy of bezier approximation
189+
190+
### Integration Tests
191+
192+
**PathBuilder integration:**
193+
194+
- `fromSvgPath()` produces correct operators
195+
- `appendSvgPath()` continues from current point
196+
- Chaining with other PathBuilder methods
197+
- Complex real-world paths (icons, shapes)
198+
199+
### Visual Tests
200+
201+
- Generate PDFs with various SVG paths
202+
- Compare with SVG rendered in browser
203+
- Test paths from real-world sources (Font Awesome icons, map data)
204+
205+
### Edge Cases
206+
207+
- Empty path string
208+
- Path with only M command (no drawing)
209+
- Very large coordinates
210+
- Very small arc radii (degenerate to line)
211+
- Zero-length arcs
212+
- Arcs with rx=0 or ry=0 (should become lines per SVG spec)
213+
214+
---
215+
216+
## Open Questions
217+
218+
1. **Error handling**: Should malformed paths throw or silently skip bad commands?
219+
220+
- **Recommendation**: Skip bad commands with console warning, continue parsing. Matches browser behavior.
221+
222+
2. **Coordinate precision**: Should we round coordinates?
223+
224+
- **Recommendation**: No rounding, preserve full precision. PDF handles it fine.
225+
226+
---
227+
228+
## Future Enhancements (Not in this plan)
229+
230+
- `parseSvgPaths(svgDocument: string)`: Extract `<path>` elements with basic styles
231+
- Transform parsing (`transform` attribute)
232+
- Style extraction (`fill`, `stroke`, `stroke-width` attributes)
233+
- Support for other SVG shape elements (`<rect>`, `<circle>`, `<ellipse>`, `<polygon>`)

CODE_STYLE.md

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -91,20 +91,66 @@ if (condition) return early;
9191
if (condition) {
9292
return early;
9393
}
94+
```
9495

95-
// Bad: single-line else
96-
if (condition) {
97-
doSomething();
98-
} else doOther();
96+
### Prefer Early Returns Over Else
9997

100-
// Good: braces on else too
101-
if (condition) {
102-
doSomething();
103-
} else {
104-
doOther();
98+
Avoid `else` and `else if` when possible. Early returns reduce nesting and make code easier to follow — once you hit an `else`, you have to mentally track "what was the condition again?" which is annoying.
99+
100+
```typescript
101+
// Bad: else creates unnecessary mental context-switching
102+
function getStatus(user: User): string {
103+
if (user.isAdmin) {
104+
return "admin";
105+
} else if (user.isModerator) {
106+
return "moderator";
107+
} else {
108+
return "user";
109+
}
110+
}
111+
112+
// Good: early returns, flat structure
113+
function getStatus(user: User): string {
114+
if (user.isAdmin) {
115+
return "admin";
116+
}
117+
118+
if (user.isModerator) {
119+
return "moderator";
120+
}
121+
122+
return "user";
123+
}
124+
125+
// Bad: nested else blocks
126+
function processData(data: Data | null): Result {
127+
if (data) {
128+
if (data.isValid) {
129+
return compute(data);
130+
} else {
131+
throw new Error("Invalid data");
132+
}
133+
} else {
134+
throw new Error("No data provided");
135+
}
136+
}
137+
138+
// Good: guard clauses with early returns
139+
function processData(data: Data | null): Result {
140+
if (!data) {
141+
throw new Error("No data provided");
142+
}
143+
144+
if (!data.isValid) {
145+
throw new Error("Invalid data");
146+
}
147+
148+
return compute(data);
105149
}
106150
```
107151

152+
Sometimes `else` is unavoidable (e.g., ternaries, complex branching where both paths continue), but don't reach for it by default.
153+
108154
## Naming Conventions
109155

110156
### Classes

0 commit comments

Comments
 (0)