From 811a28a4254b43574c8ab9e9f0bc5f9c7cd44b09 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Sun, 25 Jan 2026 23:04:23 +1100 Subject: [PATCH 1/5] feat(svg): add SVG path parsing and rendering 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 --- .agents/plans/042-svg-path-support.md | 233 ++++ CODE_STYLE.md | 64 +- content/docs/guides/drawing.mdx | 141 +++ content/docs/guides/fonts.mdx | 42 +- examples/04-drawing/draw-svg-icons.ts | 279 +++++ examples/04-drawing/draw-svg-path.ts | 228 ++++ examples/04-drawing/draw-svg-tiled.ts | 384 +++++++ src/api/drawing/index.ts | 1 + src/api/drawing/path-builder.test.ts | 134 +++ src/api/drawing/path-builder.ts | 74 ++ src/api/drawing/svg.integration.test.ts | 1292 +++++++++++++++++++++++ src/api/drawing/types.ts | 68 ++ src/api/pdf-page.ts | 79 ++ src/index.ts | 1 + src/svg/arc-to-bezier.test.ts | 360 +++++++ src/svg/arc-to-bezier.ts | 265 +++++ src/svg/index.ts | 34 + src/svg/path-executor.test.ts | 442 ++++++++ src/svg/path-executor.ts | 576 ++++++++++ src/svg/path-parser.test.ts | 593 +++++++++++ src/svg/path-parser.ts | 541 ++++++++++ 21 files changed, 5801 insertions(+), 30 deletions(-) create mode 100644 .agents/plans/042-svg-path-support.md create mode 100644 examples/04-drawing/draw-svg-icons.ts create mode 100644 examples/04-drawing/draw-svg-path.ts create mode 100644 examples/04-drawing/draw-svg-tiled.ts create mode 100644 src/api/drawing/svg.integration.test.ts create mode 100644 src/svg/arc-to-bezier.test.ts create mode 100644 src/svg/arc-to-bezier.ts create mode 100644 src/svg/index.ts create mode 100644 src/svg/path-executor.test.ts create mode 100644 src/svg/path-executor.ts create mode 100644 src/svg/path-parser.test.ts create mode 100644 src/svg/path-parser.ts diff --git a/.agents/plans/042-svg-path-support.md b/.agents/plans/042-svg-path-support.md new file mode 100644 index 0000000..edb7684 --- /dev/null +++ b/.agents/plans/042-svg-path-support.md @@ -0,0 +1,233 @@ +# SVG Path Support + +## Problem Statement + +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. + +**User request from Reddit:** + +> 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. + +## Goals + +1. Parse SVG path `d` attribute strings and render them via `PathBuilder` +2. Support all SVG path commands (M, L, H, V, C, S, Q, T, A, Z) in both absolute and relative forms +3. Integrate cleanly with existing `PathBuilder` API +4. Flip the Y-axis by default so raw SVG coordinates map correctly into PDF space (with an opt-out) + +## Non-Goals + +- Full SVG document parsing (elements like ``, ``, ``, CSS, filters) +- SVG transforms (users can apply PDF transforms separately) +- SVG units conversion (assume unitless = points, like the rest of our API) +- Page tiling/splitting (users handle this themselves with our primitives) + +## Scope + +**In scope:** + +- SVG path `d` string parser +- All path commands: M, m, L, l, H, h, V, v, C, c, S, s, Q, q, T, t, A, a, Z, z +- Arc-to-bezier conversion for the `A` command +- `PathBuilder.appendSvgPath()` instance method +- `PDFPage.drawSvgPath()` convenience method + +**Out of scope:** + +- Helper to extract paths from SVG documents (maybe later as a separate utility) +- Viewbox/coordinate system transforms +- Stroke/fill style parsing from SVG attributes + +--- + +## Desired Usage + +### Basic: Draw SVG path data + +```typescript +// Convenience method on PDFPage - fill by default +page.drawSvgPath("M 10 10 L 100 10 L 100 100 Z", { + color: rgb(1, 0, 0), +}); + +// With stroke +page.drawSvgPath("M 10 10 C 20 20, 40 20, 50 10", { + borderColor: rgb(0, 0, 0), + borderWidth: 2, +}); +``` + +### Using PathBuilder for more control + +```typescript +// When you need to choose fill vs stroke explicitly +page + .drawPath() + .appendSvgPath("M 10 10 L 100 10 L 100 100 Z") + .stroke({ borderColor: rgb(0, 0, 0) }); +``` + +### Chaining with existing PathBuilder methods + +```typescript +page + .drawPath() + .moveTo(0, 0) + .appendSvgPath("l 50 50 c 10 10 20 20 30 10") // relative commands continue from current point + .lineTo(200, 200) + .close() + .stroke(); +``` + +### Complex paths (sewing patterns, icons) + +```typescript +// Heart shape +page + .drawPath() + .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") + .fill({ color: rgb(1, 0, 0) }); + +// Multiple subpaths +page + .drawPath() + .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") + .fill({ windingRule: "evenodd" }); // Creates a square with a square hole +``` + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ PathBuilder.fromSvgPath() / .appendSvgPath() │ +│ (Entry points - high-level API) │ +├─────────────────────────────────────────────────────────────┤ +│ src/svg/path-parser.ts │ +│ (Parse d string → command objects) │ +├─────────────────────────────────────────────────────────────┤ +│ src/svg/path-executor.ts │ +│ (Execute commands via callback interface) │ +│ - Handles relative → absolute conversion │ +│ - Handles smooth curve reflection │ +│ - Handles arc → bezier conversion │ +├─────────────────────────────────────────────────────────────┤ +│ PathBuilder (existing) │ +│ (moveTo, lineTo, curveTo, quadraticCurveTo, close) │ +└─────────────────────────────────────────────────────────────┘ +``` + +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.). + +### New Files + +| File | Purpose | +| -------------------------- | ---------------------------------------------------------------------------------- | +| `src/svg/path-parser.ts` | Tokenize and parse SVG path `d` strings into command objects | +| `src/svg/path-executor.ts` | Execute parsed commands with state tracking (relative coords, smooth curves, arcs) | +| `src/svg/arc-to-bezier.ts` | Arc endpoint → center parameterization and bezier approximation | +| `src/svg/index.ts` | Public exports | + +### Modified Files + +| File | Changes | +| --------------------------------- | --------------------------------------- | +| `src/api/drawing/path-builder.ts` | Add `appendSvgPath()` instance method | +| `src/api/pdf-page.ts` | Add `drawSvgPath()` convenience method | +| `src/index.ts` | Export svg utilities for advanced users | + +--- + +## SVG Path Command Reference + +| Command | Parameters | Description | PathBuilder equivalent | +| ------- | ------------------------------- | ---------------- | --------------------------------------------- | +| M/m | x y | Move to | `moveTo(x, y)` | +| L/l | x y | Line to | `lineTo(x, y)` | +| H/h | x | Horizontal line | `lineTo(x, currentY)` | +| V/v | y | Vertical line | `lineTo(currentX, y)` | +| C/c | x1 y1 x2 y2 x y | Cubic bezier | `curveTo(...)` | +| S/s | x2 y2 x y | Smooth cubic | Reflect last CP, then `curveTo(...)` | +| Q/q | x1 y1 x y | Quadratic bezier | `quadraticCurveTo(...)` | +| T/t | x y | Smooth quadratic | Reflect last CP, then `quadraticCurveTo(...)` | +| A/a | rx ry angle large-arc sweep x y | Elliptical arc | Convert to bezier curves | +| Z/z | (none) | Close path | `close()` | + +**Lowercase = relative coordinates** (offset from current point) +**Uppercase = absolute coordinates** + +--- + +## Test Plan + +### Unit Tests + +**Parser tests (`src/svg/path-parser.test.ts`):** + +- Basic commands: M, L, H, V, C, Q, Z +- Relative commands: m, l, h, v, c, q, z +- Smooth curves: S, s, T, t +- Arcs: A, a (various flag combinations) +- Number formats: integers, decimals, negative, scientific notation +- Whitespace variations: spaces, commas, no separators +- Repeated commands (implicit repetition) +- Invalid input handling (malformed paths) + +**Executor tests (`src/svg/path-executor.test.ts`):** + +- Relative to absolute conversion +- Smooth curve control point reflection +- Arc to bezier conversion accuracy +- State tracking across commands + +**Arc conversion tests (`src/svg/arc-to-bezier.test.ts`):** + +- Various arc flag combinations (large-arc, sweep) +- Degenerate cases (zero radii, same start/end point) +- Accuracy of bezier approximation + +### Integration Tests + +**PathBuilder integration:** + +- `fromSvgPath()` produces correct operators +- `appendSvgPath()` continues from current point +- Chaining with other PathBuilder methods +- Complex real-world paths (icons, shapes) + +### Visual Tests + +- Generate PDFs with various SVG paths +- Compare with SVG rendered in browser +- Test paths from real-world sources (Font Awesome icons, map data) + +### Edge Cases + +- Empty path string +- Path with only M command (no drawing) +- Very large coordinates +- Very small arc radii (degenerate to line) +- Zero-length arcs +- Arcs with rx=0 or ry=0 (should become lines per SVG spec) + +--- + +## Open Questions + +1. **Error handling**: Should malformed paths throw or silently skip bad commands? + +- **Recommendation**: Skip bad commands with console warning, continue parsing. Matches browser behavior. + +2. **Coordinate precision**: Should we round coordinates? + +- **Recommendation**: No rounding, preserve full precision. PDF handles it fine. + +--- + +## Future Enhancements (Not in this plan) + +- `parseSvgPaths(svgDocument: string)`: Extract `` elements with basic styles +- Transform parsing (`transform` attribute) +- Style extraction (`fill`, `stroke`, `stroke-width` attributes) +- Support for other SVG shape elements (``, ``, ``, ``) diff --git a/CODE_STYLE.md b/CODE_STYLE.md index 1b98a3c..b7711ff 100644 --- a/CODE_STYLE.md +++ b/CODE_STYLE.md @@ -91,20 +91,66 @@ if (condition) return early; if (condition) { return early; } +``` -// Bad: single-line else -if (condition) { - doSomething(); -} else doOther(); +### Prefer Early Returns Over Else -// Good: braces on else too -if (condition) { - doSomething(); -} else { - doOther(); +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. + +```typescript +// Bad: else creates unnecessary mental context-switching +function getStatus(user: User): string { + if (user.isAdmin) { + return "admin"; + } else if (user.isModerator) { + return "moderator"; + } else { + return "user"; + } +} + +// Good: early returns, flat structure +function getStatus(user: User): string { + if (user.isAdmin) { + return "admin"; + } + + if (user.isModerator) { + return "moderator"; + } + + return "user"; +} + +// Bad: nested else blocks +function processData(data: Data | null): Result { + if (data) { + if (data.isValid) { + return compute(data); + } else { + throw new Error("Invalid data"); + } + } else { + throw new Error("No data provided"); + } +} + +// Good: guard clauses with early returns +function processData(data: Data | null): Result { + if (!data) { + throw new Error("No data provided"); + } + + if (!data.isValid) { + throw new Error("Invalid data"); + } + + return compute(data); } ``` +Sometimes `else` is unavoidable (e.g., ternaries, complex branching where both paths continue), but don't reach for it by default. + ## Naming Conventions ### Classes diff --git a/content/docs/guides/drawing.mdx b/content/docs/guides/drawing.mdx index e5dd7dc..0a4fff5 100644 --- a/content/docs/guides/drawing.mdx +++ b/content/docs/guides/drawing.mdx @@ -305,6 +305,147 @@ page .stroke({ borderColor: rgb(0, 0, 0), borderWidth: 2 }); ``` +## SVG Paths + +Draw vector graphics using SVG path syntax. This is useful for icons, logos, and importing graphics from SVG files. + +### Basic Usage + +Use `drawSvgPath()` to render SVG path data: + +```ts +// Simple triangle (filled by default) +page.drawSvgPath("M 0 0 L 60 0 L 30 50 Z", { + x: 50, + y: 700, + color: rgb(1, 0, 0), +}); + +// Stroked path +page.drawSvgPath("M 0 0 C 20 40 60 40 80 0", { + x: 150, + y: 700, + borderColor: rgb(0, 0, 1), + borderWidth: 2, +}); +``` + +### Coordinate System + +SVG uses a Y-down coordinate system (origin at top-left), while PDF uses Y-up (origin at bottom-left). `drawSvgPath()` automatically transforms coordinates so SVG paths render correctly. + +The `x` and `y` options position the path's origin point on the page. + +### Scaling + +Scale paths up or down with the `scale` option: + +```ts +// Original size (24x24 icon) +const heartIcon = "M 12 4 C 12 4 8 0 4 4 C 0 8 0 12 12 22 ..."; + +page.drawSvgPath(heartIcon, { x: 50, y: 700, color: rgb(1, 0, 0) }); + +// Double size +page.drawSvgPath(heartIcon, { x: 100, y: 700, scale: 2, color: rgb(1, 0, 0) }); + +// Triple size +page.drawSvgPath(heartIcon, { x: 180, y: 700, scale: 3, color: rgb(1, 0, 0) }); +``` + +### Supported Commands + +All SVG path commands are supported: + +| Command | Description | Example | +| ------- | ------------------- | --------------------- | +| M/m | Move to | `M 10 20` | +| L/l | Line to | `L 100 200` | +| H/h | Horizontal line | `H 150` | +| V/v | Vertical line | `V 100` | +| C/c | Cubic bezier | `C 10 20 30 40 50 60` | +| S/s | Smooth cubic | `S 30 40 50 60` | +| Q/q | Quadratic bezier | `Q 50 100 100 0` | +| T/t | Smooth quadratic | `T 150 0` | +| A/a | Elliptical arc | `A 50 50 0 0 1 100 0` | +| Z/z | Close path | `Z` | + +Uppercase commands use absolute coordinates; lowercase use relative coordinates. + +### Even-Odd Fill + +For paths with holes (like donuts or frames), use the even-odd winding rule: + +```ts +// Outer square with inner square hole +page.drawSvgPath( + "M 0 0 L 80 0 L 80 80 L 0 80 Z M 20 20 L 60 20 L 60 60 L 20 60 Z", + { + x: 50, + y: 600, + color: rgb(0, 0, 1), + windingRule: "evenodd", + } +); +``` + +### Using with PathBuilder + +For more control, use `appendSvgPath()` on a PathBuilder: + +```ts +page + .drawPath() + .moveTo(50, 500) + .lineTo(100, 500) + .appendSvgPath("l 30 30 l 30 -30", { flipY: false }) // Continue with SVG + .lineTo(200, 500) + .stroke({ borderColor: rgb(0, 0, 0), borderWidth: 2 }); +``` + +The `flipY: false` option keeps SVG in PDF coordinates when mixing with PathBuilder. + +### Drawing Icons + +Extract the `d` attribute from any SVG icon and use it directly: + +```ts +// From an SVG file: +const starPath = "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77..."; + +// Stroked icon (like Lucide/Feather) +page.drawSvgPath(starPath, { + x: 50, + y: 600, + scale: 2, + borderColor: rgb(0.9, 0.7, 0.1), + borderWidth: 2, +}); + +// Filled icon (like Simple Icons) +const githubPath = "M12 .297c-6.63 0-12 5.373-12 12..."; + +page.drawSvgPath(githubPath, { + x: 150, + y: 600, + scale: 2, + color: rgb(0.1, 0.1, 0.1), +}); +``` + +### Options Reference + +| Option | Type | Description | +| ------------- | -------------- | ---------------------------------------- | +| `x` | `number` | X position on page | +| `y` | `number` | Y position on page | +| `scale` | `number` | Scale factor (default: 1) | +| `color` | `Color` | Fill color | +| `borderColor` | `Color` | Stroke color | +| `borderWidth` | `number` | Stroke width in points | +| `windingRule` | `"nonzero" \| "evenodd"` | Fill rule for overlapping paths | +| `opacity` | `number` | Opacity (0-1) | + ## Drawing Order Content is drawn in order - later drawings appear on top: diff --git a/content/docs/guides/fonts.mdx b/content/docs/guides/fonts.mdx index ff442e0..b1dd265 100644 --- a/content/docs/guides/fonts.mdx +++ b/content/docs/guides/fonts.mdx @@ -24,22 +24,22 @@ page.drawText("Hello", { Available fonts: -| Name | Description | -| ------------------------ | ------------------------ | -| `Helvetica` | Sans-serif | -| `HelveticaBold` | Sans-serif bold | -| `HelveticaOblique` | Sans-serif italic | -| `HelveticaBoldOblique` | Sans-serif bold italic | -| `TimesRoman` | Serif | -| `TimesBold` | Serif bold | -| `TimesItalic` | Serif italic | -| `TimesBoldItalic` | Serif bold italic | -| `Courier` | Monospace | -| `CourierBold` | Monospace bold | -| `CourierOblique` | Monospace italic | -| `CourierBoldOblique` | Monospace bold italic | -| `Symbol` | Greek and math symbols | -| `ZapfDingbats` | Decorative symbols | +| Name | Description | +| ---------------------- | ---------------------- | +| `Helvetica` | Sans-serif | +| `HelveticaBold` | Sans-serif bold | +| `HelveticaOblique` | Sans-serif italic | +| `HelveticaBoldOblique` | Sans-serif bold italic | +| `TimesRoman` | Serif | +| `TimesBold` | Serif bold | +| `TimesItalic` | Serif italic | +| `TimesBoldItalic` | Serif bold italic | +| `Courier` | Monospace | +| `CourierBold` | Monospace bold | +| `CourierOblique` | Monospace italic | +| `CourierBoldOblique` | Monospace bold italic | +| `Symbol` | Greek and math symbols | +| `ZapfDingbats` | Decorative symbols | --- @@ -94,12 +94,12 @@ const height = font.heightAtSize(12); Available measurement methods: -| Method | Description | -| ---------------------------- | ---------------------------------------- | -| `widthOfTextAtSize(text, size)` | Width of text in points | +| Method | Description | +| ------------------------------- | ----------------------------------------- | +| `widthOfTextAtSize(text, size)` | Width of text in points | | `heightAtSize(size)` | Full height (ascent to descent) in points | -| `sizeAtWidth(text, width)` | Font size to fit text within a width | -| `sizeAtHeight(height)` | Font size to achieve a height | +| `sizeAtWidth(text, width)` | Font size to fit text within a width | +| `sizeAtHeight(height)` | Font size to achieve a height | ### Measuring with Embedded Fonts diff --git a/examples/04-drawing/draw-svg-icons.ts b/examples/04-drawing/draw-svg-icons.ts new file mode 100644 index 0000000..76bc5a9 --- /dev/null +++ b/examples/04-drawing/draw-svg-icons.ts @@ -0,0 +1,279 @@ +/** + * Example: Draw SVG Icons + * + * This example shows how to render real SVG icons from icon libraries + * onto PDF pages. Icons are extracted from popular open-source libraries. + * + * Run: npx tsx examples/04-drawing/draw-svg-icons.ts + */ + +import { black, grayscale, PDF, rgb } from "../../src/index"; +import { formatBytes, saveOutput } from "../utils"; + +// ============================================================================= +// SVG Icon Paths (from popular open-source icon libraries) +// ============================================================================= + +// Simple Icons (CC0 license) - Brand icons, 24x24 viewBox, filled +const SIMPLE_ICONS = { + github: + "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12", + typescript: + "M1.125 0C.502 0 0 .502 0 1.125v21.75C0 23.498.502 24 1.125 24h21.75c.623 0 1.125-.502 1.125-1.125V1.125C24 .502 23.498 0 22.875 0zm17.363 9.75c.612 0 1.154.037 1.627.111a6.38 6.38 0 0 1 1.306.34v2.458a3.95 3.95 0 0 0-.643-.361 5.093 5.093 0 0 0-.717-.26 5.453 5.453 0 0 0-1.426-.2c-.3 0-.573.028-.819.086a2.1 2.1 0 0 0-.623.242c-.17.104-.3.229-.393.374a.888.888 0 0 0-.14.49c0 .196.053.373.156.529.104.156.252.304.443.444s.423.276.696.41c.273.135.582.274.926.416.47.197.892.407 1.266.628.374.222.695.473.963.753.268.279.472.598.614.957.142.359.214.776.214 1.253 0 .657-.125 1.21-.373 1.656a3.033 3.033 0 0 1-1.012 1.085 4.38 4.38 0 0 1-1.487.596c-.566.12-1.163.18-1.79.18a9.916 9.916 0 0 1-1.84-.164 5.544 5.544 0 0 1-1.512-.493v-2.63a5.033 5.033 0 0 0 3.237 1.2c.333 0 .624-.03.872-.09.249-.06.456-.144.623-.25.166-.108.29-.234.373-.38a1.023 1.023 0 0 0-.074-1.089 2.12 2.12 0 0 0-.537-.5 5.597 5.597 0 0 0-.807-.444 27.72 27.72 0 0 0-1.007-.436c-.918-.383-1.602-.852-2.053-1.405-.45-.553-.676-1.222-.676-2.005 0-.614.123-1.141.369-1.582.246-.441.58-.804 1.004-1.089a4.494 4.494 0 0 1 1.47-.629 7.536 7.536 0 0 1 1.77-.201zm-15.113.188h9.563v2.166H9.506v9.646H6.789v-9.646H3.375z", + npm: "M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.977 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z", +}; + +// Lucide Icons (MIT license) - UI icons, 24x24 viewBox, stroked +const LUCIDE_ICONS = { + heart: + "M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z", + star: "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z", + home: "M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M9 22V12h6v10", + mail: "M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z M22 6l-10 7L2 6", + search: "M11 3a8 8 0 1 0 0 16 8 8 0 0 0 0-16z M21 21l-4.35-4.35", + user: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2 M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z", +}; + +async function main() { + console.log("Drawing SVG icons...\n"); + + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + const { height } = page; + + // Title + page.drawText("SVG Icon Examples", { + x: 180, + y: height - 40, + size: 24, + color: black, + }); + + page.drawText("Real icons from popular open-source icon libraries", { + x: 130, + y: height - 60, + size: 11, + color: grayscale(0.5), + }); + + // === Simple Icons (Filled brand icons) === + console.log("Drawing Simple Icons (brand logos)..."); + + page.drawText("Simple Icons (CC0) - Filled brand icons:", { + x: 50, + y: height - 100, + size: 14, + color: black, + }); + + // GitHub + page.drawText("GitHub", { x: 60, y: height - 175, size: 10, color: grayscale(0.6) }); + page.drawSvgPath(SIMPLE_ICONS.github, { + x: 50, + y: height - 120, + scale: 2, + color: grayscale(0.15), + }); + + // TypeScript + page.drawText("TypeScript", { x: 160, y: height - 175, size: 10, color: grayscale(0.6) }); + page.drawSvgPath(SIMPLE_ICONS.typescript, { + x: 150, + y: height - 120, + scale: 2, + color: rgb(0.19, 0.47, 0.71), + }); + + // npm + page.drawText("npm", { x: 275, y: height - 175, size: 10, color: grayscale(0.6) }); + page.drawSvgPath(SIMPLE_ICONS.npm, { + x: 250, + y: height - 120, + scale: 2, + color: rgb(0.8, 0.22, 0.17), + }); + + // === Lucide Icons (Stroked UI icons) === + console.log("Drawing Lucide Icons (UI icons)..."); + + page.drawText("Lucide Icons (MIT) - Stroked UI icons:", { + x: 50, + y: height - 220, + size: 14, + color: black, + }); + + const lucideY = height - 240; + const iconSpacing = 80; + let iconX = 50; + + // Heart + page.drawText("heart", { x: iconX + 10, y: lucideY - 65, size: 10, color: grayscale(0.6) }); + page.drawSvgPath(LUCIDE_ICONS.heart, { + x: iconX, + y: lucideY, + scale: 2, + borderColor: rgb(0.9, 0.2, 0.2), + borderWidth: 2, + }); + iconX += iconSpacing; + + // Star + page.drawText("star", { x: iconX + 12, y: lucideY - 65, size: 10, color: grayscale(0.6) }); + page.drawSvgPath(LUCIDE_ICONS.star, { + x: iconX, + y: lucideY, + scale: 2, + borderColor: rgb(0.9, 0.7, 0.1), + borderWidth: 2, + }); + iconX += iconSpacing; + + // Home + page.drawText("home", { x: iconX + 10, y: lucideY - 65, size: 10, color: grayscale(0.6) }); + page.drawSvgPath(LUCIDE_ICONS.home, { + x: iconX, + y: lucideY, + scale: 2, + borderColor: rgb(0.2, 0.6, 0.4), + borderWidth: 2, + }); + iconX += iconSpacing; + + // Mail + page.drawText("mail", { x: iconX + 12, y: lucideY - 65, size: 10, color: grayscale(0.6) }); + page.drawSvgPath(LUCIDE_ICONS.mail, { + x: iconX, + y: lucideY, + scale: 2, + borderColor: rgb(0.5, 0.3, 0.7), + borderWidth: 2, + }); + iconX += iconSpacing; + + // Search + page.drawText("search", { x: iconX + 6, y: lucideY - 65, size: 10, color: grayscale(0.6) }); + page.drawSvgPath(LUCIDE_ICONS.search, { + x: iconX, + y: lucideY, + scale: 2, + borderColor: rgb(0.3, 0.5, 0.8), + borderWidth: 2, + }); + iconX += iconSpacing; + + // User + page.drawText("user", { x: iconX + 12, y: lucideY - 65, size: 10, color: grayscale(0.6) }); + page.drawSvgPath(LUCIDE_ICONS.user, { + x: iconX, + y: lucideY, + scale: 2, + borderColor: grayscale(0.4), + borderWidth: 2, + }); + + // === Icon Sizing Demo === + console.log("Demonstrating icon scaling..."); + + page.drawText("Icon Scaling:", { x: 50, y: height - 360, size: 14, color: black }); + + const scales = [1, 1.5, 2, 2.5, 3]; + let scaleX = 50; + + for (const scale of scales) { + page.drawText(`${scale}x`, { + x: scaleX + 8, + y: height - 450, + size: 10, + color: grayscale(0.5), + }); + + page.drawSvgPath(LUCIDE_ICONS.heart, { + x: scaleX, + y: height - 380, + scale, + borderColor: rgb(0.9, 0.2, 0.2), + borderWidth: 1.5, + }); + + scaleX += 24 * scale + 30; + } + + // === Practical Use: Icon Buttons === + console.log("Creating icon buttons..."); + + page.drawText("Practical Example - Icon Buttons:", { + x: 50, + y: height - 500, + size: 14, + color: black, + }); + + const buttonY = height - 560; + const buttonSize = 48; + const buttonPadding = 12; + let buttonX = 50; + + const buttonIcons = [ + { path: LUCIDE_ICONS.home, color: rgb(0.2, 0.5, 0.8) }, + { path: LUCIDE_ICONS.search, color: rgb(0.5, 0.3, 0.7) }, + { path: LUCIDE_ICONS.mail, color: rgb(0.8, 0.3, 0.3) }, + { path: LUCIDE_ICONS.user, color: rgb(0.3, 0.6, 0.4) }, + { path: LUCIDE_ICONS.star, color: rgb(0.9, 0.7, 0.1) }, + ]; + + for (const { path, color } of buttonIcons) { + // Draw button background + page.drawRectangle({ + x: buttonX, + y: buttonY, + width: buttonSize, + height: buttonSize, + color: rgb(0.95, 0.95, 0.97), + borderColor: grayscale(0.85), + borderWidth: 1, + cornerRadius: 8, + }); + + // Draw icon centered in button + page.drawSvgPath(path, { + x: buttonX + buttonPadding, + y: buttonY + buttonSize - buttonPadding, + borderColor: color, + borderWidth: 1.5, + }); + + buttonX += buttonSize + 16; + } + + // === Footer note === + page.drawLine({ + start: { x: 50, y: 80 }, + end: { x: 562, y: 80 }, + color: grayscale(0.8), + }); + + page.drawText("Icon paths are from SVG d attributes. Extract from any SVG icon library.", { + x: 50, + y: 60, + size: 10, + color: grayscale(0.5), + }); + + page.drawText("Simple Icons: simpleicons.org | Lucide: lucide.dev", { + x: 50, + y: 45, + size: 10, + color: grayscale(0.5), + }); + + // Save the document + console.log("\n=== Saving Document ==="); + const savedBytes = await pdf.save(); + const outputPath = await saveOutput("04-drawing/svg-icon-examples.pdf", savedBytes); + + console.log(`Output: ${outputPath}`); + console.log(`Size: ${formatBytes(savedBytes.length)}`); +} + +main().catch(console.error); diff --git a/examples/04-drawing/draw-svg-path.ts b/examples/04-drawing/draw-svg-path.ts new file mode 100644 index 0000000..6e39b48 --- /dev/null +++ b/examples/04-drawing/draw-svg-path.ts @@ -0,0 +1,228 @@ +/** + * Example: Draw SVG Path + * + * This example demonstrates how to render SVG path data onto PDF pages. + * Useful for icons, logos, and importing vector graphics from SVG files. + * + * Run: npx tsx examples/04-drawing/draw-svg-path.ts + */ + +import { black, blue, grayscale, PDF, red, rgb } from "../../src/index"; +import { formatBytes, saveOutput } from "../utils"; + +async function main() { + console.log("Drawing SVG paths...\n"); + + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + const { height } = page; + + // Title + page.drawText("SVG Path Support", { + x: 200, + y: height - 40, + size: 20, + color: black, + }); + + // === Simple Triangle === + console.log("Drawing triangle from SVG path..."); + + page.drawText("Triangle:", { x: 50, y: height - 80, size: 12, color: black }); + + // SVG path for a triangle pointing down (in SVG coordinates) + // drawSvgPath automatically transforms SVG coords (Y-down) to PDF (Y-up) + page.drawSvgPath("M 0 0 L 60 0 L 30 50 Z", { + x: 50, + y: height - 90, + color: red, + }); + + // === Star Shape === + console.log("Drawing star from SVG path..."); + + page.drawText("Star:", { x: 200, y: height - 80, size: 12, color: black }); + + // 5-pointed star (SVG coordinates) + const starPath = + "M 25 0 L 31 18 L 50 18 L 35 29 L 40 47 L 25 36 L 10 47 L 15 29 L 0 18 L 19 18 Z"; + page.drawSvgPath(starPath, { + x: 200, + y: height - 90, + color: rgb(1, 0.8, 0), + }); + + // === Arrow Icon === + console.log("Drawing arrow icon..."); + + page.drawText("Arrow:", { x: 350, y: height - 80, size: 12, color: black }); + + const arrowPath = "M 0 15 L 30 15 L 30 5 L 50 25 L 30 45 L 30 35 L 0 35 Z"; + page.drawSvgPath(arrowPath, { + x: 350, + y: height - 90, + color: blue, + }); + + // === Bezier Curves === + console.log("Drawing curves from SVG path..."); + + page.drawText("Cubic Bezier (C command):", { x: 50, y: height - 200, size: 12, color: black }); + + page.drawSvgPath("M 0 30 C 20 0 60 60 80 30 C 100 0 140 60 160 30", { + x: 50, + y: height - 215, + borderColor: rgb(0.8, 0.2, 0.5), + borderWidth: 2, + }); + + page.drawText("Quadratic Bezier (Q command):", { + x: 300, + y: height - 200, + size: 12, + color: black, + }); + + page.drawSvgPath("M 0 0 Q 40 50 80 0 Q 120 50 160 0", { + x: 300, + y: height - 215, + borderColor: rgb(0.2, 0.6, 0.8), + borderWidth: 2, + }); + + // === Relative Commands === + console.log("Drawing with relative commands..."); + + page.drawText("Relative commands (lowercase):", { + x: 50, + y: height - 300, + size: 12, + color: black, + }); + + // Staircase using relative line commands + page.drawSvgPath("M 0 0 l 30 0 l 0 20 l 30 0 l 0 20 l 30 0 l 0 20", { + x: 50, + y: height - 320, + borderColor: grayscale(0.3), + borderWidth: 2, + }); + + // === Horizontal/Vertical Lines === + page.drawText("H/V commands:", { x: 300, y: height - 300, size: 12, color: black }); + + // Grid pattern using H and V + page.drawSvgPath( + "M 0 0 H 80 V 60 H 0 V 0 M 20 0 V 60 M 40 0 V 60 M 60 0 V 60 M 0 20 H 80 M 0 40 H 80", + { + x: 300, + y: height - 320, + borderColor: grayscale(0.4), + borderWidth: 1, + }, + ); + + // === Arcs === + console.log("Drawing arcs..."); + + page.drawText("Arcs (A command):", { x: 50, y: height - 420, size: 12, color: black }); + + // Smiley face using arcs + // Face outline + page.drawSvgPath("M 40 0 A 40 40 0 1 1 40 80 A 40 40 0 1 1 40 0", { + x: 50, + y: height - 440, + borderColor: rgb(0.9, 0.7, 0.1), + borderWidth: 3, + }); + + // Left eye + page.drawSvgPath("M 25 25 A 5 5 0 1 1 25 35 A 5 5 0 1 1 25 25", { + x: 50, + y: height - 440, + color: black, + }); + + // Right eye + page.drawSvgPath("M 55 25 A 5 5 0 1 1 55 35 A 5 5 0 1 1 55 25", { + x: 50, + y: height - 440, + color: black, + }); + + // Smile + page.drawSvgPath("M 20 50 A 25 25 0 0 0 60 50", { + x: 50, + y: height - 440, + borderColor: black, + borderWidth: 2, + }); + + // === Even-Odd Fill Rule === + console.log("Drawing with even-odd fill..."); + + page.drawText("Even-odd fill (hole):", { x: 250, y: height - 420, size: 12, color: black }); + + // Nested squares - creates a hole with even-odd + page.drawSvgPath("M 0 0 L 80 0 L 80 80 L 0 80 Z M 20 20 L 60 20 L 60 60 L 20 60 Z", { + x: 250, + y: height - 440, + color: blue, + windingRule: "evenodd", + }); + + // === Scaling === + console.log("Drawing scaled icons..."); + + page.drawText("Scaled icons:", { x: 50, y: height - 560, size: 12, color: black }); + + // Heart icon at different scales + const heartPath = "M 12 4 C 12 4 8 0 4 4 C 0 8 0 12 12 22 C 24 12 24 8 20 4 C 16 0 12 4 12 4 Z"; + + page.drawText("1x", { x: 55, y: height - 600, size: 10, color: grayscale(0.5) }); + page.drawSvgPath(heartPath, { + x: 50, + y: height - 585, + color: red, + }); + + page.drawText("2x", { x: 115, y: height - 600, size: 10, color: grayscale(0.5) }); + page.drawSvgPath(heartPath, { + x: 100, + y: height - 590, + scale: 2, + color: red, + }); + + page.drawText("3x", { x: 215, y: height - 600, size: 10, color: grayscale(0.5) }); + page.drawSvgPath(heartPath, { + x: 200, + y: height - 600, + scale: 3, + color: red, + }); + + // === PathBuilder Integration === + console.log("Using appendSvgPath with PathBuilder..."); + + page.drawText("PathBuilder chaining:", { x: 50, y: height - 680, size: 12, color: black }); + + // Mix SVG path with PathBuilder methods + page + .drawPath() + .moveTo(50, height - 750) + .lineTo(100, height - 750) + .appendSvgPath("l 30 -30 l 30 30", { flipY: false }) // relative SVG continues from current point + .lineTo(210, height - 750) + .stroke({ borderColor: rgb(0.5, 0.3, 0.7), borderWidth: 2 }); + + // Save the document + console.log("\n=== Saving Document ==="); + const savedBytes = await pdf.save(); + const outputPath = await saveOutput("04-drawing/svg-path-examples.pdf", savedBytes); + + console.log(`Output: ${outputPath}`); + console.log(`Size: ${formatBytes(savedBytes.length)}`); +} + +main().catch(console.error); diff --git a/examples/04-drawing/draw-svg-tiled.ts b/examples/04-drawing/draw-svg-tiled.ts new file mode 100644 index 0000000..3a37b6f --- /dev/null +++ b/examples/04-drawing/draw-svg-tiled.ts @@ -0,0 +1,384 @@ +/** + * Example: Draw Tiled SVG Paths + * + * This example demonstrates how to tile SVG paths across a page, useful for: + * - Sewing patterns that need to be printed on multiple sheets + * - Large technical drawings split across pages + * - Repeating patterns and backgrounds + * + * Run: npx tsx examples/04-drawing/draw-svg-tiled.ts + */ + +import { black, blue, grayscale, PDF, red, rgb } from "../../src/index"; +import { formatBytes, saveOutput } from "../utils"; + +// A complex sewing pattern-like path (simplified dress pattern piece) +const PATTERN_PATH = ` + M 0 0 + L 80 0 + C 85 20 90 60 85 100 + L 80 180 + C 75 200 60 220 50 230 + L 40 230 + C 30 220 15 200 10 180 + L 5 100 + C 0 60 5 20 10 0 + Z + M 35 40 + A 10 10 0 1 1 35 60 + A 10 10 0 1 1 35 40 +`; + +// A decorative tile pattern (floral-ish) +const TILE_PATTERN = ` + M 25 5 + C 30 0 35 5 35 10 + C 40 5 45 10 45 15 + C 50 15 50 25 45 25 + C 50 30 45 35 40 35 + C 45 40 40 45 35 45 + C 35 50 25 50 25 45 + C 20 50 15 45 15 40 + C 10 45 5 40 5 35 + C 0 35 0 25 5 25 + C 0 20 5 15 10 15 + C 5 10 10 5 15 5 + C 15 0 25 0 25 5 + Z +`; + +async function main() { + console.log("Drawing tiled SVG paths...\n"); + + const pdf = PDF.create(); + + // ========================================================================== + // Page 1: Tiled background pattern + // ========================================================================== + console.log("Creating tiled background pattern..."); + + const page1 = pdf.addPage({ size: "letter" }); + const { width: w1, height: h1 } = page1; + + page1.drawText("Tiled Background Pattern", { + x: 180, + y: h1 - 40, + size: 20, + color: black, + }); + + // Draw a grid of decorative tiles + const tileSize = 50; + const tileScale = 1; + const tilesX = Math.ceil(w1 / tileSize); + const tilesY = Math.ceil((h1 - 100) / tileSize); + + for (let row = 0; row < tilesY; row++) { + for (let col = 0; col < tilesX; col++) { + const x = col * tileSize; + const y = h1 - 80 - row * tileSize; + + // Alternate colors for checkerboard effect + const isEven = (row + col) % 2 === 0; + const color = isEven ? rgb(0.85, 0.9, 0.95) : rgb(0.9, 0.85, 0.9); + + page1.drawSvgPath(TILE_PATTERN, { + x, + y, + scale: tileScale, + color, + borderColor: grayscale(0.7), + borderWidth: 0.5, + }); + } + } + + // Add text on top + page1.drawRectangle({ + x: 100, + y: h1 / 2 - 30, + width: w1 - 200, + height: 60, + color: rgb(1, 1, 1), + borderColor: grayscale(0.3), + borderWidth: 1, + cornerRadius: 8, + }); + + page1.drawText("Content on top of tiled background", { + x: 160, + y: h1 / 2 - 5, + size: 16, + color: black, + }); + + // ========================================================================== + // Page 2: Large pattern split across the page (simulating multi-page print) + // ========================================================================== + console.log("Creating large pattern with tile markers..."); + + const page2 = pdf.addPage({ size: "letter" }); + const { width: w2, height: h2 } = page2; + + page2.drawText("Large Pattern with Print Tiles", { + x: 160, + y: h2 - 40, + size: 20, + color: black, + }); + + page2.drawText("Pattern scaled large, with tile boundaries shown for multi-sheet printing", { + x: 90, + y: h2 - 60, + size: 10, + color: grayscale(0.5), + }); + + // Draw the pattern large + const patternScale = 4; + const patternX = 100; + const patternY = h2 - 100; + + page2.drawSvgPath(PATTERN_PATH, { + x: patternX, + y: patternY, + scale: patternScale, + color: rgb(0.95, 0.9, 0.85), + borderColor: rgb(0.6, 0.4, 0.2), + borderWidth: 2, + }); + + // Draw tile boundaries (simulating how it would be split for printing) + const tileW = 150; + const tileH = 200; + const patternWidth = 90 * patternScale; + const patternHeight = 230 * patternScale; + + // Draw tile grid + page2.drawText("Tile boundaries:", { x: 400, y: h2 - 100, size: 10, color: grayscale(0.5) }); + + for (let ty = 0; ty * tileH < patternHeight; ty++) { + for (let tx = 0; tx * tileW < patternWidth; tx++) { + const tileX = patternX + tx * tileW; + const tileY = patternY - ty * tileH; + + // Draw dashed tile boundary + page2.drawRectangle({ + x: tileX, + y: tileY - tileH, + width: tileW, + height: tileH, + borderColor: red, + borderWidth: 1, + }); + + // Label the tile + page2.drawText(`${ty + 1}-${tx + 1}`, { + x: tileX + 5, + y: tileY - 15, + size: 8, + color: red, + }); + } + } + + // ========================================================================== + // Page 3: Actual multi-page tiling (pattern split across pages) + // ========================================================================== + console.log("Creating multi-page tiled output..."); + + // For this demo, we'll create a 2x2 grid of pages showing different parts + // of a large pattern + + const largePatternScale = 8; + const sourceWidth = 90 * largePatternScale; // ~720 + const sourceHeight = 230 * largePatternScale; // ~1840 + + // Tile size matches a portion of a letter page (with margins) + const printTileW = 500; + const printTileH = 700; + + const tilesAcross = Math.ceil(sourceWidth / printTileW); + const tilesDown = Math.ceil(sourceHeight / printTileH); + + console.log(` Pattern size: ${sourceWidth} x ${sourceHeight} points`); + console.log(` Tiles needed: ${tilesAcross} x ${tilesDown} = ${tilesAcross * tilesDown} pages`); + + for (let tileRow = 0; tileRow < tilesDown; tileRow++) { + for (let tileCol = 0; tileCol < tilesAcross; tileCol++) { + const tilePage = pdf.addPage({ size: "letter" }); + const { width: tw, height: th } = tilePage; + + // Calculate offset to show this portion of the pattern + const offsetX = -tileCol * printTileW; + const offsetY = tileRow * printTileH; + + // Header + tilePage.drawText(`Tile ${tileRow + 1}-${tileCol + 1}`, { + x: 50, + y: th - 30, + size: 14, + color: black, + }); + + tilePage.drawText( + `(Row ${tileRow + 1} of ${tilesDown}, Column ${tileCol + 1} of ${tilesAcross})`, + { + x: 50, + y: th - 45, + size: 9, + color: grayscale(0.5), + }, + ); + + const margin = 50; + const markLen = 20; + + // Draw the pattern with offset FIRST (so it's behind everything) + // Position the pattern so the correct portion is visible + tilePage.drawSvgPath(PATTERN_PATH, { + x: margin + offsetX, + y: th - margin - 30 + offsetY, + scale: largePatternScale, + color: rgb(0.95, 0.92, 0.88), + borderColor: rgb(0.5, 0.35, 0.2), + borderWidth: 1.5, + }); + + // Draw crop marks at corners (on top of pattern) + // Top-left + tilePage.drawLine({ + start: { x: margin, y: th - margin - markLen }, + end: { x: margin, y: th - margin }, + color: grayscale(0.3), + }); + tilePage.drawLine({ + start: { x: margin, y: th - margin }, + end: { x: margin + markLen, y: th - margin }, + color: grayscale(0.3), + }); + + // Top-right + tilePage.drawLine({ + start: { x: tw - margin, y: th - margin - markLen }, + end: { x: tw - margin, y: th - margin }, + color: grayscale(0.3), + }); + tilePage.drawLine({ + start: { x: tw - margin - markLen, y: th - margin }, + end: { x: tw - margin, y: th - margin }, + color: grayscale(0.3), + }); + + // Bottom-left + tilePage.drawLine({ + start: { x: margin, y: margin }, + end: { x: margin, y: margin + markLen }, + color: grayscale(0.3), + }); + tilePage.drawLine({ + start: { x: margin, y: margin }, + end: { x: margin + markLen, y: margin }, + color: grayscale(0.3), + }); + + // Bottom-right + tilePage.drawLine({ + start: { x: tw - margin, y: margin }, + end: { x: tw - margin, y: margin + markLen }, + color: grayscale(0.3), + }); + tilePage.drawLine({ + start: { x: tw - margin - markLen, y: margin }, + end: { x: tw - margin, y: margin }, + color: grayscale(0.3), + }); + + // Clip region border (on top of pattern) + tilePage.drawRectangle({ + x: margin, + y: margin, + width: tw - margin * 2, + height: th - margin * 2 - 30, + borderColor: grayscale(0.8), + borderWidth: 0.5, + }); + + // Registration marks for alignment + if (tileCol > 0) { + // Left edge alignment mark + tilePage.drawText("<< align", { + x: margin + 5, + y: th / 2, + size: 8, + color: blue, + }); + } + if (tileCol < tilesAcross - 1) { + // Right edge alignment mark + tilePage.drawText("align >>", { + x: tw - margin - 40, + y: th / 2, + size: 8, + color: blue, + }); + } + } + } + + // ========================================================================== + // Final page: Assembly instructions + // ========================================================================== + console.log("Adding assembly instructions..."); + + const instrPage = pdf.addPage({ size: "letter" }); + const { width: wi, height: hi } = instrPage; + + instrPage.drawText("Assembly Instructions", { + x: 200, + y: hi - 50, + size: 20, + color: black, + }); + + const instructions = [ + "1. Print all tile pages at 100% scale (no scaling)", + "2. Cut along the crop marks on each page", + "3. Arrange tiles in a grid:", + "", + " +-------+-------+", + " | 1-1 | 1-2 |", + " +-------+-------+", + " | 2-1 | 2-2 |", + " +-------+-------+", + " | 3-1 | 3-2 |", + " +-------+-------+", + "", + "4. Align the << and >> marks between adjacent tiles", + "5. Tape tiles together from the back", + "6. Cut out the pattern along the solid line", + ]; + + let instrY = hi - 100; + for (const line of instructions) { + instrPage.drawText(line, { + x: 100, + y: instrY, + size: 12, + font: "Courier", + color: black, + }); + instrY -= 20; + } + + // Save the document + console.log("\n=== Saving Document ==="); + const savedBytes = await pdf.save(); + const outputPath = await saveOutput("04-drawing/svg-tiled-examples.pdf", savedBytes); + + console.log(`Output: ${outputPath}`); + console.log(`Size: ${formatBytes(savedBytes.length)}`); + console.log(`Pages: ${pdf.getPageCount()}`); +} + +main().catch(console.error); diff --git a/src/api/drawing/index.ts b/src/api/drawing/index.ts index 2c45e66..45be553 100644 --- a/src/api/drawing/index.ts +++ b/src/api/drawing/index.ts @@ -21,6 +21,7 @@ export type { DrawImageOptions, DrawLineOptions, DrawRectangleOptions, + DrawSvgPathOptions, DrawTextOptions, FontInput, LineCap, diff --git a/src/api/drawing/path-builder.test.ts b/src/api/drawing/path-builder.test.ts index 58f2adf..b572edd 100644 --- a/src/api/drawing/path-builder.test.ts +++ b/src/api/drawing/path-builder.test.ts @@ -208,4 +208,138 @@ describe("PathBuilder", () => { expect(appendContent).toHaveBeenCalled(); }); }); + + describe("appendSvgPath", () => { + it("parses and appends simple SVG path", () => { + const { builder, appendContent } = createBuilder(); + + builder.appendSvgPath("M 10 20 L 100 200").stroke(); + + const content = appendContent.mock.calls[0][0]; + expect(content).toContain("10 20 m"); + expect(content).toContain("100 200 l"); + }); + + it("handles relative commands from current position", () => { + const { builder, appendContent } = createBuilder(); + + // Start at (100, 100), then draw relative line (50, 50) + builder.moveTo(100, 100).appendSvgPath("l 50 50").stroke(); + + const content = appendContent.mock.calls[0][0]; + expect(content).toContain("100 100 m"); + expect(content).toContain("150 150 l"); // 100+50, 100+50 + }); + + it("handles triangle path", () => { + const { builder, appendContent } = createBuilder(); + + builder.appendSvgPath("M 10 10 L 100 10 L 55 90 Z").fill(); + + const content = appendContent.mock.calls[0][0]; + expect(content).toContain("10 10 m"); + expect(content).toContain("100 10 l"); + expect(content).toContain("55 90 l"); + expect(content).toContain("h"); // close path + }); + + it("handles cubic bezier curves", () => { + const { builder, appendContent } = createBuilder(); + + builder.appendSvgPath("M 0 0 C 10 20 30 40 50 60").stroke(); + + const content = appendContent.mock.calls[0][0]; + expect(content).toContain("10 20 30 40 50 60 c"); + }); + + it("handles quadratic curves (converted to cubic)", () => { + const { builder, appendContent } = createBuilder(); + + builder.appendSvgPath("M 0 0 Q 50 100 100 0").stroke(); + + const content = appendContent.mock.calls[0][0]; + // Should have a cubic curve (quadratic converted) + expect(content).toMatch(/c/); + }); + + it("handles smooth cubic curves (S)", () => { + const { builder, appendContent } = createBuilder(); + + builder.appendSvgPath("M 0 0 C 20 20 80 80 100 100 S 180 180 200 200").stroke(); + + const content = appendContent.mock.calls[0][0]; + // Should have two cubic curves + expect((content.match(/\d+ c/g) || []).length).toBe(2); + }); + + it("handles arcs (converted to beziers)", () => { + const { builder, appendContent } = createBuilder(); + + builder.appendSvgPath("M 100 50 A 50 50 0 0 1 50 100").stroke(); + + const content = appendContent.mock.calls[0][0]; + // Arc should be converted to at least one bezier curve + expect(content).toMatch(/c/); + }); + + it("handles horizontal and vertical lines", () => { + const { builder, appendContent } = createBuilder(); + + builder.appendSvgPath("M 0 0 H 100 V 50").stroke(); + + const content = appendContent.mock.calls[0][0]; + expect(content).toContain("100 0 l"); // H 100 from (0,0) + expect(content).toContain("100 50 l"); // V 50 from (100,0) + }); + + it("chains with other PathBuilder methods", () => { + const { builder, appendContent } = createBuilder(); + + builder.moveTo(0, 0).appendSvgPath("l 50 50").lineTo(200, 200).close().stroke(); + + const content = appendContent.mock.calls[0][0]; + expect(content).toContain("0 0 m"); + expect(content).toContain("50 50 l"); // relative from (0,0) + expect(content).toContain("200 200 l"); // absolute + expect(content).toContain("h"); + }); + + it("handles multiple subpaths", () => { + const { builder, appendContent } = createBuilder(); + + builder + .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") + .fill({ windingRule: "evenodd" }); + + const content = appendContent.mock.calls[0][0]; + // Two move-to commands for two subpaths + expect((content.match(/ m/g) || []).length).toBe(2); + // Two close-path commands + expect((content.match(/h/g) || []).length).toBe(2); + }); + + it("handles heart shape with arcs", () => { + const { builder, appendContent } = createBuilder(); + + builder + .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", + ) + .fill(); + + const content = appendContent.mock.calls[0][0]; + expect(content).toContain("10 30 m"); // Move to start + // Should have bezier curves (from arcs and quadratics) + expect(content).toMatch(/c/); + expect(content).toContain("h"); // Close path + }); + + it("returns this for chaining", () => { + const { builder } = createBuilder(); + + const result = builder.appendSvgPath("M 0 0 L 100 100"); + + expect(result).toBe(builder); + }); + }); }); diff --git a/src/api/drawing/path-builder.ts b/src/api/drawing/path-builder.ts index 3d0af84..c0d2369 100644 --- a/src/api/drawing/path-builder.ts +++ b/src/api/drawing/path-builder.ts @@ -7,6 +7,7 @@ import type { Operator } from "#src/content/operators"; import { clip, clipEvenOdd, closePath, curveTo, lineTo, moveTo } from "#src/helpers/operators"; +import { executeSvgPathString, type SvgPathExecutorOptions } from "#src/svg/path-executor"; import { wrapPathOps } from "./operations"; import type { PathOptions } from "./types"; @@ -194,6 +195,79 @@ export class PathBuilder { .close(); } + // ───────────────────────────────────────────────────────────────────────────── + // SVG Path Support + // ───────────────────────────────────────────────────────────────────────────── + + /** + * Append an SVG path string to the current path. + * + * Parses the SVG path `d` attribute string and adds all commands to this path. + * Relative commands (lowercase) are converted to absolute coordinates based on + * the current point. Smooth curves (S, T) and arcs (A) are converted to + * cubic bezier curves. + * + * By default, this method does NOT transform coordinates (flipY: false). + * Use the options to apply scale, translation, and Y-flip for SVG paths. + * + * @param pathData - SVG path `d` attribute string + * @param options - Execution options (flipY, scale, translate) + * @returns This PathBuilder for chaining + * + * @example + * ```typescript + * // Simple path (no transform) + * page.drawPath() + * .appendSvgPath("M 10 10 L 100 10 L 55 90 Z") + * .fill({ color: rgb(1, 0, 0) }); + * + * // SVG icon with full transform + * page.drawPath() + * .appendSvgPath(iconPath, { + * flipY: true, + * scale: 0.1, + * translateX: 100, + * translateY: 500, + * }) + * .fill({ color: rgb(0, 0, 0) }); + * ``` + */ + appendSvgPath(pathData: string, options: SvgPathExecutorOptions = {}): this { + // Default flipY to false for PathBuilder (caller must opt-in to transform) + const executorOptions: SvgPathExecutorOptions = { + flipY: options.flipY ?? false, + scale: options.scale, + translateX: options.translateX, + translateY: options.translateY, + }; + + executeSvgPathString( + pathData, + { + moveTo: (x: number, y: number) => { + this.moveTo(x, y); + }, + lineTo: (x: number, y: number) => { + this.lineTo(x, y); + }, + curveTo: (cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number) => { + this.curveTo(cp1x, cp1y, cp2x, cp2y, x, y); + }, + quadraticCurveTo: (cpx: number, cpy: number, x: number, y: number) => { + this.quadraticCurveTo(cpx, cpy, x, y); + }, + close: () => { + this.close(); + }, + }, + this.currentX, + this.currentY, + executorOptions, + ); + + return this; + } + // ───────────────────────────────────────────────────────────────────────────── // Painting (Terminates Path) // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/api/drawing/svg.integration.test.ts b/src/api/drawing/svg.integration.test.ts new file mode 100644 index 0000000..478e459 --- /dev/null +++ b/src/api/drawing/svg.integration.test.ts @@ -0,0 +1,1292 @@ +/** + * Integration tests for SVG path support in the Drawing API. + * + * These tests generate actual PDF files that can be visually inspected + * in the test-output directory. + */ + +import { black, blue, grayscale, green, red, rgb } from "#src/helpers/colors"; +import { PdfStream } from "#src/objects/pdf-stream"; +import { isPdfHeader, saveTestOutput } from "#src/test-utils"; +import { describe, expect, it } from "vitest"; + +import { PDF } from "../pdf"; + +describe("SVG Path Integration", () => { + it("fills SVG paths with black by default", () => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + + page.drawSvgPath("M 0 0 L 10 0 L 0 10 Z"); + + const contents = page.dict.get("Contents", pdf.context.resolve.bind(pdf.context)); + expect(contents).toBeInstanceOf(PdfStream); + + const contentText = new TextDecoder().decode((contents as PdfStream).data); + expect(contentText).toMatch(/(^|\n)0 g(\n|$)/); + expect(contentText).toMatch(/(^|\n)f(\n|$)/); + }); + + it("draws SVG paths with drawSvgPath and appendSvgPath", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + + // Title + page.drawText("SVG Path Support", { + x: 50, + y: 720, + font: "Helvetica-Bold", + size: 24, + color: black, + }); + + page.drawLine({ + start: { x: 50, y: 710 }, + end: { x: 400, y: 710 }, + color: grayscale(0.5), + }); + + page.drawText("All paths use standard SVG coordinates (Y-down), automatically transformed.", { + x: 50, + y: 695, + size: 9, + color: grayscale(0.5), + }); + + // Simple triangle pointing down (in SVG, Y increases downward) + page.drawText("Triangle (points down):", { x: 50, y: 660, size: 10, color: black }); + page.drawSvgPath("M 0 0 L 50 0 L 25 40 Z", { x: 60, y: 645, color: red }); + + // 5-pointed star (standard SVG star path) + page.drawText("Star:", { x: 150, y: 660, size: 10, color: black }); + const starPath = + "M 25 0 L 31 18 L 50 18 L 35 29 L 40 47 L 25 36 L 10 47 L 15 29 L 0 18 L 19 18 Z"; + page.drawSvgPath(starPath, { x: 160, y: 650, color: rgb(1, 0.8, 0) }); + + // Heart shape using bezier curves + page.drawText("Heart (beziers):", { x: 270, y: 660, size: 10, color: black }); + // Classic heart: two lobes at top, point at bottom + const heartPath = + "M 25 8 C 25 0 15 0 10 5 C 0 15 0 20 25 40 C 50 20 50 15 40 5 C 35 0 25 0 25 8 Z"; + page.drawSvgPath(heartPath, { x: 280, y: 655, color: rgb(1, 0.2, 0.4) }); + + // Wave pattern using cubic beziers + page.drawText("Wave (cubic beziers):", { x: 400, y: 660, size: 10, color: black }); + page.drawSvgPath("M 0 20 C 20 0 40 40 60 20 C 80 0 100 40 120 20 C 140 0 160 40 180 20", { + x: 400, + y: 645, + borderColor: blue, + borderWidth: 2, + }); + + // Staircase using relative commands (going down-right in SVG = going up-right in PDF) + page.drawText("Staircase (relative):", { x: 50, y: 560, size: 10, color: black }); + page.drawSvgPath("M 0 0 l 25 0 l 0 15 l 25 0 l 0 15 l 25 0 l 0 15 l 25 0 l 0 15", { + x: 50, + y: 545, + borderColor: green, + borderWidth: 2, + }); + + // Grid using H and V commands + page.drawText("Grid (H/V lines):", { x: 220, y: 560, size: 10, color: black }); + page.drawSvgPath( + "M 0 0 H 80 V 60 H 0 V 0 M 20 0 V 60 M 40 0 V 60 M 60 0 V 60 M 0 20 H 80 M 0 40 H 80", + { x: 220, y: 555, borderColor: grayscale(0.3), borderWidth: 1 }, + ); + + // Smooth cubic curves (S command) + page.drawText("Smooth curves (S):", { x: 380, y: 560, size: 10, color: black }); + page.drawSvgPath("M 0 30 C 0 0 30 0 30 30 S 60 60 60 30 S 90 0 90 30", { + x: 380, + y: 545, + borderColor: rgb(0.6, 0.2, 0.8), + borderWidth: 2, + }); + + // Quadratic curves (Q command) + page.drawText("Quadratic curves (Q):", { x: 50, y: 460, size: 10, color: black }); + page.drawSvgPath("M 0 0 Q 40 50 80 0 Q 120 50 160 0 Q 200 50 240 0", { + x: 50, + y: 450, + borderColor: rgb(0.8, 0.4, 0), + borderWidth: 2, + }); + + // Smooth quadratic (T command) + page.drawText("Smooth quadratic (T):", { x: 350, y: 460, size: 10, color: black }); + page.drawSvgPath("M 0 20 Q 20 0 40 20 T 80 20 T 120 20 T 160 20", { + x: 350, + y: 450, + borderColor: rgb(0, 0.6, 0.6), + borderWidth: 2, + }); + + // Smiley face using arcs + page.drawText("Arcs - Smiley:", { x: 50, y: 360, size: 10, color: black }); + // Face outline (circle using two arcs) + page.drawSvgPath("M 40 0 A 40 40 0 1 1 40 80 A 40 40 0 1 1 40 0", { + x: 60, + y: 350, + borderColor: rgb(0.9, 0.7, 0.1), + borderWidth: 3, + }); + // Left eye + page.drawSvgPath("M 25 25 A 5 5 0 1 1 25 35 A 5 5 0 1 1 25 25", { + x: 60, + y: 350, + color: black, + }); + // Right eye + page.drawSvgPath("M 55 25 A 5 5 0 1 1 55 35 A 5 5 0 1 1 55 25", { + x: 60, + y: 350, + color: black, + }); + // Smile (arc curving down in SVG = smile in PDF) + page.drawSvgPath("M 20 50 A 25 25 0 0 0 60 50", { + x: 60, + y: 350, + borderColor: black, + borderWidth: 2, + }); + + // Even-odd fill rule - nested squares (creates hole) + page.drawText("Even-odd fill (hole):", { x: 200, y: 360, size: 10, color: black }); + page.drawSvgPath("M 0 0 L 80 0 L 80 80 L 0 80 Z M 20 20 L 60 20 L 60 60 L 20 60 Z", { + x: 200, + y: 355, + color: blue, + windingRule: "evenodd", + }); + + // Multiple subpaths - checkerboard pattern + page.drawText("Multiple subpaths:", { x: 320, y: 360, size: 10, color: black }); + page.drawSvgPath( + "M 0 0 L 20 0 L 20 20 L 0 20 Z " + + "M 40 0 L 60 0 L 60 20 L 40 20 Z " + + "M 20 20 L 40 20 L 40 40 L 20 40 Z " + + "M 0 40 L 20 40 L 20 60 L 0 60 Z " + + "M 40 40 L 60 40 L 60 60 L 40 60 Z", + { x: 320, y: 355, color: black }, + ); + + // Arrow icon (custom path, not from any icon library) + page.drawText("Arrow icon:", { x: 440, y: 360, size: 10, color: black }); + const arrowPath = "M 0 15 L 30 15 L 30 5 L 50 25 L 30 45 L 30 35 L 0 35 Z"; + page.drawSvgPath(arrowPath, { x: 440, y: 355, color: rgb(0.3, 0.3, 0.7) }); + + // Chaining with PathBuilder methods + page.drawText("Chaining with PathBuilder:", { x: 50, y: 240, size: 10, color: black }); + page + .drawPath() + .moveTo(50, 220) + .appendSvgPath("l 30 0 l -15 -30 z", { flipY: false }) // triangle in PDF coords + .fill({ color: rgb(0.5, 0.8, 0.5) }); + + // Leaf shape using quadratic curves + page.drawText("Leaf shape:", { x: 150, y: 240, size: 10, color: black }); + const leafPath = "M 25 0 Q 50 25 25 50 Q 0 25 25 0 Z"; + page.drawSvgPath(leafPath, { x: 160, y: 230, color: rgb(0.3, 0.7, 0.3) }); + + // Crescent moon using arcs + page.drawText("Crescent:", { x: 250, y: 240, size: 10, color: black }); + const crescentPath = "M 20 0 A 20 20 0 1 1 20 40 A 15 15 0 1 0 20 0 Z"; + page.drawSvgPath(crescentPath, { x: 260, y: 235, color: rgb(0.9, 0.8, 0.2) }); + + // Footer + page.drawLine({ + start: { x: 50, y: 50 }, + end: { x: 562, y: 50 }, + color: grayscale(0.7), + }); + + page.drawText("SVG path commands: M, L, H, V, C, S, Q, T, A, Z (and lowercase relative)", { + x: 50, + y: 35, + size: 9, + color: grayscale(0.5), + }); + + const bytes = await pdf.save(); + expect(isPdfHeader(bytes)).toBe(true); + await saveTestOutput("drawing/svg-paths.pdf", bytes); + }); + + it("draws paths designed for PDF coordinates", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + + // Title + page.drawText("SVG Paths in PDF Coordinate System", { + x: 50, + y: 720, + font: "Helvetica-Bold", + size: 20, + color: black, + }); + + page.drawLine({ + start: { x: 50, y: 710 }, + end: { x: 450, y: 710 }, + color: grayscale(0.5), + }); + + page.drawText("Note: PDF uses bottom-left origin (Y increases upward)", { + x: 50, + y: 690, + size: 10, + color: grayscale(0.5), + }); + + // Row 1: Basic shapes drawn with SVG paths + // NOTE: flipY: false because these paths use raw PDF coordinates (Y-up) + page.drawText("Heart (beziers):", { x: 50, y: 650, size: 10, color: black }); + // Heart shape - designed for PDF coordinates (Y up) + page.drawSvgPath( + "M 100 600 C 100 620 80 635 60 635 C 35 635 20 615 20 595 C 20 565 60 540 100 515 C 140 540 180 565 180 595 C 180 615 165 635 140 635 C 120 635 100 620 100 600 Z", + { color: rgb(0.9, 0.2, 0.3), flipY: false }, + ); + + page.drawText("Star (lines):", { x: 220, y: 650, size: 10, color: black }); + // 5-pointed star + page.drawSvgPath( + "M 280 640 L 290 610 L 320 610 L 295 590 L 305 560 L 280 578 L 255 560 L 265 590 L 240 610 L 270 610 Z", + { color: rgb(1, 0.8, 0), flipY: false }, + ); + + page.drawText("Arrow (mixed):", { x: 370, y: 650, size: 10, color: black }); + // Right-pointing arrow + page.drawSvgPath( + "M 380 600 L 380 620 L 440 620 L 440 635 L 480 605 L 440 575 L 440 590 L 380 590 Z", + { color: rgb(0.2, 0.6, 0.9), flipY: false }, + ); + + // Row 2: Curves + page.drawText("Spiral (arcs):", { x: 50, y: 530, size: 10, color: black }); + page.drawSvgPath( + "M 100 480 A 15 15 0 0 1 100 510 A 20 20 0 0 1 100 470 A 25 25 0 0 1 100 520 A 30 30 0 0 1 100 460", + { borderColor: rgb(0.5, 0.2, 0.7), borderWidth: 2, flipY: false }, + ); + + page.drawText("Waves (cubic):", { x: 180, y: 530, size: 10, color: black }); + page.drawSvgPath( + "M 180 490 C 200 520 220 460 240 490 C 260 520 280 460 300 490 C 320 520 340 460 360 490", + { borderColor: rgb(0.2, 0.7, 0.5), borderWidth: 2, flipY: false }, + ); + + page.drawText("Smooth S-curve:", { x: 400, y: 530, size: 10, color: black }); + page.drawSvgPath("M 400 490 C 420 520 440 520 460 490 S 500 460 520 490", { + borderColor: rgb(0.8, 0.4, 0.2), + borderWidth: 2, + flipY: false, + }); + + // Row 3: Shapes with holes (even-odd) + page.drawText("Donut (even-odd):", { x: 50, y: 420, size: 10, color: black }); + // Outer circle, then inner circle - even-odd creates hole + page.drawSvgPath("M 100 410 A 30 30 0 1 0 100 410.01 Z M 100 395 A 15 15 0 1 1 100 394.99 Z", { + color: rgb(0.6, 0.4, 0.2), + windingRule: "evenodd", + flipY: false, + }); + + page.drawText("Frame (even-odd):", { x: 180, y: 420, size: 10, color: black }); + page.drawSvgPath( + "M 180 400 L 280 400 L 280 340 L 180 340 Z M 200 380 L 260 380 L 260 360 L 200 360 Z", + { color: rgb(0.3, 0.5, 0.7), windingRule: "evenodd", flipY: false }, + ); + + page.drawText("Badge:", { x: 320, y: 420, size: 10, color: black }); + // Shield/badge shape + page.drawSvgPath( + "M 370 400 L 420 400 L 420 360 C 420 340 395 320 395 320 C 395 320 370 340 370 360 Z", + { + color: rgb(0.8, 0.2, 0.2), + borderColor: rgb(0.6, 0.1, 0.1), + borderWidth: 2, + flipY: false, + }, + ); + + // Row 4: Relative commands demonstration + page.drawText("Relative staircase:", { x: 50, y: 300, size: 10, color: black }); + page.drawSvgPath("M 50 280 l 20 0 l 0 20 l 20 0 l 0 20 l 20 0 l 0 20 l 20 0", { + borderColor: grayscale(0.3), + borderWidth: 2, + flipY: false, + }); + + page.drawText("Relative zigzag:", { x: 180, y: 300, size: 10, color: black }); + page.drawSvgPath("M 180 260 l 20 30 l 20 -30 l 20 30 l 20 -30 l 20 30 l 20 -30", { + borderColor: rgb(0.9, 0.5, 0.1), + borderWidth: 2, + flipY: false, + }); + + page.drawText("H/V lines grid:", { x: 350, y: 300, size: 10, color: black }); + page.drawSvgPath( + "M 350 280 H 430 V 220 H 350 V 280 M 370 280 V 220 M 390 280 V 220 M 410 280 V 220 M 350 260 H 430 M 350 240 H 430", + { borderColor: grayscale(0.4), borderWidth: 1, flipY: false }, + ); + + // Row 5: Quadratic curves + page.drawText("Quadratic bounce:", { x: 50, y: 190, size: 10, color: black }); + page.drawSvgPath("M 50 150 Q 80 190 110 150 T 170 150 T 230 150", { + borderColor: rgb(0.2, 0.6, 0.8), + borderWidth: 2, + flipY: false, + }); + + page.drawText("Leaf shape:", { x: 280, y: 190, size: 10, color: black }); + page.drawSvgPath("M 330 170 Q 350 130 380 150 Q 410 170 380 190 Q 350 210 330 170 Z", { + color: rgb(0.3, 0.7, 0.3), + flipY: false, + }); + + // Footer + page.drawLine({ + start: { x: 50, y: 60 }, + end: { x: 562, y: 60 }, + color: grayscale(0.7), + }); + page.drawText( + "All paths drawn using SVG path syntax with coordinates designed for PDF (Y increases upward)", + { + x: 50, + y: 45, + size: 9, + color: grayscale(0.5), + }, + ); + + const bytes = await pdf.save(); + expect(isPdfHeader(bytes)).toBe(true); + await saveTestOutput("drawing/svg-paths-showcase.pdf", bytes); + }); + + it("draws SVG icons with automatic coordinate transform", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + + page.drawText("SVG Path Transform Demo", { x: 50, y: 750, size: 24, color: black }); + page.drawLine({ + start: { x: 50, y: 740 }, + end: { x: 550, y: 740 }, + color: grayscale(0.5), + }); + page.drawText("drawSvgPath() automatically converts SVG coordinates (Y-down) to PDF (Y-up).", { + x: 50, + y: 720, + size: 10, + color: grayscale(0.5), + }); + page.drawText("Use x, y to position and scale to resize.", { + x: 50, + y: 705, + size: 10, + color: grayscale(0.5), + }); + + // Simple triangle - in SVG this points DOWN (Y increases downward) + // After transform it should point DOWN in PDF too (visually correct) + const trianglePath = "M 0 0 L 50 0 L 25 40 Z"; + + page.drawText("Triangle: M 0 0 L 50 0 L 25 40 Z", { x: 50, y: 650, size: 11, color: black }); + page.drawText("(Points down in SVG, renders pointing down)", { + x: 50, + y: 635, + size: 9, + color: grayscale(0.5), + }); + + page.drawSvgPath(trianglePath, { + x: 70, + y: 620, + color: rgb(0.2, 0.5, 0.9), + }); + + // Arrow pointing right in SVG coordinates + const arrowPath = "M 0 15 L 30 15 L 30 5 L 50 25 L 30 45 L 30 35 L 0 35 Z"; + + page.drawText("Right arrow:", { x: 50, y: 530, size: 11, color: black }); + page.drawSvgPath(arrowPath, { + x: 70, + y: 525, + color: rgb(0.2, 0.7, 0.3), + }); + + // Same arrow scaled down + page.drawText("Same arrow at 50% scale:", { x: 200, y: 530, size: 11, color: black }); + page.drawSvgPath(arrowPath, { + x: 220, + y: 520, + scale: 0.5, + color: rgb(0.8, 0.3, 0.3), + }); + + // Custom heart icon (original path, not from any library) + const heartPath = "M 12 4 C 12 4 8 0 4 4 C 0 8 0 12 12 22 C 24 12 24 8 20 4 C 16 0 12 4 12 4 Z"; + + page.drawText("Heart icon (custom 24x24):", { + x: 50, + y: 420, + size: 11, + color: black, + }); + + // At original size + page.drawText("1x:", { x: 70, y: 390, size: 10, color: black }); + page.drawSvgPath(heartPath, { + x: 90, + y: 395, + color: rgb(0.9, 0.2, 0.2), + }); + + // Scaled 2x + page.drawText("2x:", { x: 150, y: 390, size: 10, color: black }); + page.drawSvgPath(heartPath, { + x: 170, + y: 400, + scale: 2, + color: rgb(0.9, 0.2, 0.2), + }); + + // Scaled 3x + page.drawText("3x:", { x: 270, y: 390, size: 10, color: black }); + page.drawSvgPath(heartPath, { + x: 290, + y: 410, + scale: 3, + color: rgb(0.9, 0.2, 0.2), + }); + + // Checkmark icon (custom path) + const checkPath = "M 2 12 L 9 19 L 22 6 L 20 4 L 9 15 L 4 10 Z"; + + page.drawText("Checkmark icon:", { x: 50, y: 280, size: 11, color: black }); + page.drawSvgPath(checkPath, { + x: 70, + y: 285, + scale: 2, + color: rgb(0.2, 0.7, 0.3), + }); + + // Close/X icon (custom path) + const closePath = + "M 4 4 L 12 12 L 4 20 L 6 22 L 14 14 L 22 22 L 24 20 L 16 12 L 24 4 L 22 2 L 14 10 L 6 2 Z"; + + page.drawText("Close icon:", { x: 200, y: 280, size: 11, color: black }); + page.drawSvgPath(closePath, { + x: 220, + y: 285, + scale: 2, + color: rgb(0.8, 0.2, 0.2), + }); + + // Plus icon (custom path) + const plusPath = + "M 10 2 L 14 2 L 14 10 L 22 10 L 22 14 L 14 14 L 14 22 L 10 22 L 10 14 L 2 14 L 2 10 L 10 10 Z"; + + page.drawText("Plus icon:", { x: 350, y: 280, size: 11, color: black }); + page.drawSvgPath(plusPath, { + x: 370, + y: 285, + scale: 2, + color: rgb(0.2, 0.5, 0.8), + }); + + // Note + page.drawRectangle({ + x: 50, + y: 60, + width: 500, + height: 50, + color: rgb(0.95, 0.95, 0.95), + borderColor: grayscale(0.7), + borderWidth: 0.5, + }); + page.drawText("SVG paths are automatically transformed:", { + x: 60, + y: 95, + size: 10, + color: black, + }); + page.drawText("Y coordinates flipped, then scaled and translated to (x, y) position.", { + x: 60, + y: 80, + size: 9, + color: grayscale(0.4), + }); + + const bytes = await pdf.save(); + expect(isPdfHeader(bytes)).toBe(true); + await saveTestOutput("drawing/svg-paths-transform.pdf", bytes); + }); + + it("draws complex real-world SVG icons", async () => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: "letter" }); + + page.drawText("Complex Real-World SVG Icons", { x: 50, y: 750, size: 20, color: black }); + page.drawLine({ + start: { x: 50, y: 740 }, + end: { x: 550, y: 740 }, + color: grayscale(0.5), + }); + + // === Simple Icons (CC0 licensed) - Complex filled brand logos === + // These are 24x24 viewBox, filled paths + + // Simple Icons: GitHub (24x24) - CC0 + const siGithub = + "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"; + + page.drawText("Simple Icons: GitHub (24x24):", { x: 50, y: 710, size: 11, color: black }); + page.drawSvgPath(siGithub, { + x: 50, + y: 700, + scale: 2, + color: grayscale(0.1), + }); + + // Simple Icons: TypeScript (24x24) - CC0 + const siTypescript = + "M1.125 0C.502 0 0 .502 0 1.125v21.75C0 23.498.502 24 1.125 24h21.75c.623 0 1.125-.502 1.125-1.125V1.125C24 .502 23.498 0 22.875 0zm17.363 9.75c.612 0 1.154.037 1.627.111a6.38 6.38 0 0 1 1.306.34v2.458a3.95 3.95 0 0 0-.643-.361 5.093 5.093 0 0 0-.717-.26 5.453 5.453 0 0 0-1.426-.2c-.3 0-.573.028-.819.086a2.1 2.1 0 0 0-.623.242c-.17.104-.3.229-.393.374a.888.888 0 0 0-.14.49c0 .196.053.373.156.529.104.156.252.304.443.444s.423.276.696.41c.273.135.582.274.926.416.47.197.892.407 1.266.628.374.222.695.473.963.753.268.279.472.598.614.957.142.359.214.776.214 1.253 0 .657-.125 1.21-.373 1.656a3.033 3.033 0 0 1-1.012 1.085 4.38 4.38 0 0 1-1.487.596c-.566.12-1.163.18-1.79.18a9.916 9.916 0 0 1-1.84-.164 5.544 5.544 0 0 1-1.512-.493v-2.63a5.033 5.033 0 0 0 3.237 1.2c.333 0 .624-.03.872-.09.249-.06.456-.144.623-.25.166-.108.29-.234.373-.38a1.023 1.023 0 0 0-.074-1.089 2.12 2.12 0 0 0-.537-.5 5.597 5.597 0 0 0-.807-.444 27.72 27.72 0 0 0-1.007-.436c-.918-.383-1.602-.852-2.053-1.405-.45-.553-.676-1.222-.676-2.005 0-.614.123-1.141.369-1.582.246-.441.58-.804 1.004-1.089a4.494 4.494 0 0 1 1.47-.629 7.536 7.536 0 0 1 1.77-.201zm-15.113.188h9.563v2.166H9.506v9.646H6.789v-9.646H3.375z"; + + page.drawText("Simple Icons: TypeScript (24x24):", { + x: 160, + y: 710, + size: 11, + color: black, + }); + page.drawSvgPath(siTypescript, { + x: 160, + y: 700, + scale: 2, + color: rgb(0.19, 0.47, 0.71), + }); + + // Simple Icons: npm (24x24) - CC0 + const siNpm = + "M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.977 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z"; + + page.drawText("Simple Icons: npm (24x24):", { x: 280, y: 710, size: 11, color: black }); + page.drawSvgPath(siNpm, { + x: 280, + y: 700, + scale: 2, + color: rgb(0.8, 0.22, 0.17), + }); + + // Simple Icons: Docker (24x24) - CC0 + const siDocker = + "M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"; + + page.drawText("Simple Icons: Docker (24x24):", { x: 400, y: 710, size: 11, color: black }); + page.drawSvgPath(siDocker, { + x: 400, + y: 700, + scale: 2, + color: rgb(0.09, 0.46, 0.82), + }); + + // === Lucide Icons (MIT licensed) - Stroke-based UI icons === + // These are 24x24 viewBox, stroke paths (use borderColor, not color) + + // Lucide: heart (24x24) - MIT + const lucideHeart = + "M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"; + + page.drawText("Lucide: Heart (stroke):", { x: 50, y: 580, size: 11, color: black }); + page.drawSvgPath(lucideHeart, { + x: 50, + y: 570, + scale: 2, + borderColor: rgb(0.9, 0.2, 0.2), + borderWidth: 2, + }); + + // Lucide: star (24x24) - MIT + const lucideStar = + "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"; + + page.drawText("Lucide: Star (stroke):", { x: 160, y: 580, size: 11, color: black }); + page.drawSvgPath(lucideStar, { + x: 160, + y: 570, + scale: 2, + borderColor: rgb(0.9, 0.7, 0.1), + borderWidth: 2, + }); + + // Lucide: user (24x24) - MIT + const lucideUser = + "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2 M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z"; + + page.drawText("Lucide: User (stroke):", { x: 280, y: 580, size: 11, color: black }); + page.drawSvgPath(lucideUser, { + x: 280, + y: 570, + scale: 2, + borderColor: rgb(0.3, 0.5, 0.8), + borderWidth: 2, + }); + + // Lucide: mail (24x24) - MIT + const lucideMail = + "M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z M22 6l-10 7L2 6"; + + page.drawText("Lucide: Mail (stroke):", { x: 400, y: 580, size: 11, color: black }); + page.drawSvgPath(lucideMail, { + x: 400, + y: 570, + scale: 2, + borderColor: rgb(0.6, 0.3, 0.7), + borderWidth: 2, + }); + + // Lucide: home (24x24) - MIT + const lucideHome = "M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M9 22V12h6v10"; + + page.drawText("Lucide: Home (stroke):", { x: 50, y: 450, size: 11, color: black }); + page.drawSvgPath(lucideHome, { + x: 50, + y: 440, + scale: 2, + borderColor: rgb(0.2, 0.6, 0.4), + borderWidth: 2, + }); + + // Lucide: settings (24x24) - MIT + const lucideSettings = + "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z"; + + page.drawText("Lucide: Settings (stroke):", { x: 160, y: 450, size: 11, color: black }); + page.drawSvgPath(lucideSettings, { + x: 160, + y: 440, + scale: 2, + borderColor: grayscale(0.4), + borderWidth: 1.5, + }); + + // Lucide: search (24x24) - MIT + const lucideSearch = "M11 3a8 8 0 1 0 0 16 8 8 0 0 0 0-16z M21 21l-4.35-4.35"; + + page.drawText("Lucide: Search (stroke):", { x: 280, y: 450, size: 11, color: black }); + page.drawSvgPath(lucideSearch, { + x: 280, + y: 440, + scale: 2, + borderColor: rgb(0.4, 0.4, 0.8), + borderWidth: 2, + }); + + // Lucide: bell (24x24) - MIT + const lucideBell = "M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9 M13.73 21a2 2 0 0 1-3.46 0"; + + page.drawText("Lucide: Bell (stroke):", { x: 400, y: 450, size: 11, color: black }); + page.drawSvgPath(lucideBell, { + x: 400, + y: 440, + scale: 2, + borderColor: rgb(0.8, 0.5, 0.1), + borderWidth: 2, + }); + + // === Decorative paths === + + // Wave pattern using quadratic bezier curves + const wavePath = "M0 20 Q 15 0 30 20 T 60 20 T 90 20 T 120 20"; + + page.drawText("Wave pattern (Q curves):", { x: 50, y: 330, size: 11, color: black }); + page.drawSvgPath(wavePath, { + x: 50, + y: 310, + scale: 1.5, + borderColor: rgb(0.2, 0.6, 0.8), + borderWidth: 2, + }); + + // Lucide: file-text (24x24) - MIT + const lucideFileText = + "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6 M16 13H8 M16 17H8 M10 9H8"; + + page.drawText("Lucide: File (stroke):", { x: 300, y: 330, size: 11, color: black }); + page.drawSvgPath(lucideFileText, { + x: 300, + y: 320, + scale: 2, + borderColor: rgb(0.5, 0.3, 0.6), + borderWidth: 1.5, + }); + + // More Simple Icons for variety + + // Simple Icons: Bun (24x24) - CC0 + const siBun = + "M12 22.596c6.628 0 12-4.338 12-9.688 0-3.318-2.057-6.248-5.219-7.986-1.286-.715-2.297-1.357-3.139-1.89C14.058 2.025 13.08 1.404 12 1.404c-1.097 0-2.334.785-3.966 1.821a49.92 49.92 0 0 1-2.816 1.697C2.057 6.66 0 9.59 0 12.908c0 5.35 5.372 9.687 12 9.687v.001ZM10.599 4.715c.334-.759.503-1.58.498-2.409 0-.145.202-.187.23-.029.658 2.783-.902 4.162-2.057 4.624-.124.048-.199-.121-.103-.209a5.763 5.763 0 0 0 1.432-1.977Zm2.058-.102a5.82 5.82 0 0 0-.782-2.306v-.016c-.069-.123.086-.263.185-.172 1.962 2.111 1.307 4.067.556 5.051-.082.103-.23-.003-.189-.126a5.85 5.85 0 0 0 .23-2.431Zm1.776-.561a5.727 5.727 0 0 0-1.612-1.806v-.014c-.112-.085-.024-.274.114-.218 2.595 1.087 2.774 3.18 2.459 4.407a.116.116 0 0 1-.049.071.11.11 0 0 1-.153-.026.122.122 0 0 1-.022-.083 5.891 5.891 0 0 0-.737-2.331Zm-5.087.561c-.617.546-1.282.76-2.063 1-.117 0-.195-.078-.156-.181 1.752-.909 2.376-1.649 2.999-2.778 0 0 .155-.118.188.085 0 .304-.349 1.329-.968 1.874Zm4.945 11.237a2.957 2.957 0 0 1-.937 1.553c-.346.346-.8.565-1.286.62a2.178 2.178 0 0 1-1.327-.62 2.955 2.955 0 0 1-.925-1.553.244.244 0 0 1 .064-.198.234.234 0 0 1 .193-.069h3.965a.226.226 0 0 1 .19.07c.05.053.073.125.063.197Zm-5.458-2.176a1.862 1.862 0 0 1-2.384-.245 1.98 1.98 0 0 1-.233-2.447c.207-.319.503-.566.848-.713a1.84 1.84 0 0 1 1.092-.11c.366.075.703.261.967.531a1.98 1.98 0 0 1 .408 2.114 1.931 1.931 0 0 1-.698.869v.001Zm8.495.005a1.86 1.86 0 0 1-2.381-.253 1.964 1.964 0 0 1-.547-1.366c0-.384.11-.76.32-1.079.207-.319.503-.567.849-.713a1.844 1.844 0 0 1 1.093-.108c.367.076.704.262.968.534a1.98 1.98 0 0 1 .4 2.117 1.932 1.932 0 0 1-.702.868Z"; + + page.drawText("Simple Icons: Bun (24x24):", { x: 50, y: 230, size: 11, color: black }); + page.drawSvgPath(siBun, { + x: 50, + y: 220, + scale: 2, + color: rgb(0.98, 0.76, 0.62), + }); + + // Simple Icons: Node.js (24x24) - CC0 + const siNodejs = + "M11.998,24c-0.321,0-0.641-0.084-0.922-0.247l-2.936-1.737c-0.438-0.245-0.224-0.332-0.08-0.383 c0.585-0.203,0.703-0.25,1.328-0.604c0.065-0.037,0.151-0.023,0.218,0.017l2.256,1.339c0.082,0.045,0.197,0.045,0.272,0l8.795-5.076 c0.082-0.047,0.134-0.141,0.134-0.238V6.921c0-0.099-0.053-0.192-0.137-0.242l-8.791-5.072c-0.081-0.047-0.189-0.047-0.271,0 L3.075,6.68C2.99,6.729,2.936,6.825,2.936,6.921v10.15c0,0.097,0.054,0.189,0.139,0.235l2.409,1.392 c1.307,0.654,2.108-0.116,2.108-0.89V7.787c0-0.142,0.114-0.253,0.256-0.253h1.115c0.139,0,0.255,0.112,0.255,0.253v10.021 c0,1.745-0.95,2.745-2.604,2.745c-0.508,0-0.909,0-2.026-0.551L2.28,18.675c-0.57-0.329-0.922-0.945-0.922-1.604V6.921 c0-0.659,0.353-1.275,0.922-1.603l8.795-5.082c0.557-0.315,1.296-0.315,1.848,0l8.794,5.082c0.57,0.329,0.924,0.944,0.924,1.603 v10.15c0,0.659-0.354,1.273-0.924,1.604l-8.794,5.078C12.643,23.916,12.324,24,11.998,24z M19.099,13.993 c0-1.9-1.284-2.406-3.987-2.763c-2.731-0.361-3.009-0.548-3.009-1.187c0-0.528,0.235-1.233,2.258-1.233 c1.807,0,2.473,0.389,2.747,1.607c0.024,0.115,0.129,0.199,0.247,0.199h1.141c0.071,0,0.138-0.031,0.186-0.081 c0.048-0.054,0.074-0.123,0.067-0.196c-0.177-2.098-1.571-3.076-4.388-3.076c-2.508,0-4.004,1.058-4.004,2.833 c0,1.925,1.488,2.457,3.895,2.695c2.88,0.282,3.103,0.703,3.103,1.269c0,0.983-0.789,1.402-2.642,1.402 c-2.327,0-2.839-0.584-3.011-1.742c-0.02-0.124-0.126-0.215-0.253-0.215h-1.137c-0.141,0-0.254,0.112-0.254,0.253 c0,1.482,0.806,3.248,4.655,3.248C17.501,17.007,19.099,15.91,19.099,13.993z"; + + page.drawText("Simple Icons: Node.js (24x24):", { x: 160, y: 230, size: 11, color: black }); + page.drawSvgPath(siNodejs, { + x: 160, + y: 220, + scale: 2, + color: rgb(0.2, 0.52, 0.29), + }); + + // Simple Icons: Rust (24x24) - CC0 + const siRust = + "M23.8346 11.7033l-1.0073-.6236a13.7268 13.7268 0 00-.0283-.2936l.8656-.8069a.3483.3483 0 00-.1154-.578l-1.1066-.414a8.4958 8.4958 0 00-.087-.2856l.6904-.9587a.3462.3462 0 00-.2257-.5446l-1.1663-.1894a9.3574 9.3574 0 00-.1407-.2622l.49-1.0761a.3437.3437 0 00-.0274-.3361.3486.3486 0 00-.3006-.154l-1.1845.0416a6.7444 6.7444 0 00-.1873-.2268l.2723-1.153a.3472.3472 0 00-.417-.4172l-1.1532.2724a14.0183 14.0183 0 00-.2278-.1873l.0415-1.1845a.3442.3442 0 00-.49-.328l-1.076.491c-.0872-.0476-.1742-.0952-.2623-.1407l-.1903-1.1673A.3483.3483 0 0016.256.955l-.9597.6905a8.4867 8.4867 0 00-.2855-.086l-.414-1.1066a.3483.3483 0 00-.5781-.1154l-.8069.8666a9.2936 9.2936 0 00-.2936-.0284L12.2946.1683a.3462.3462 0 00-.5892 0l-.6236 1.0073a13.7383 13.7383 0 00-.2936.0284L9.9803.3374a.3462.3462 0 00-.578.1154l-.4141 1.1065c-.0962.0274-.1903.0567-.2855.086L7.744.955a.3483.3483 0 00-.5447.2258L7.009 2.348a9.3574 9.3574 0 00-.2622.1407l-1.0762-.491a.3462.3462 0 00-.49.328l.0416 1.1845a7.9826 7.9826 0 00-.2278.1873L3.8413 3.425a.3472.3472 0 00-.4171.4171l.2713 1.1531c-.0628.075-.1255.1509-.1863.2268l-1.1845-.0415a.3462.3462 0 00-.328.49l.491 1.0761a9.167 9.167 0 00-.1407.2622l-1.1662.1894a.3483.3483 0 00-.2258.5446l.6904.9587a13.303 13.303 0 00-.087.2855l-1.1065.414a.3483.3483 0 00-.1155.5781l.8656.807a9.2936 9.2936 0 00-.0283.2935l-1.0073.6236a.3442.3442 0 000 .5892l1.0073.6236c.008.0982.0182.1964.0283.2936l-.8656.8079a.3462.3462 0 00.1155.578l1.1065.4141c.0273.0962.0567.1914.087.2855l-.6904.9587a.3452.3452 0 00.2268.5447l1.1662.1893c.0456.088.0922.1751.1408.2622l-.491 1.0762a.3462.3462 0 00.328.49l1.1834-.0415c.0618.0769.1235.1528.1873.2277l-.2713 1.1541a.3462.3462 0 00.4171.4161l1.153-.2713c.075.0638.151.1255.2279.1863l-.0415 1.1845a.3442.3442 0 00.49.327l1.0761-.49c.087.0486.1741.0951.2622.1407l.1903 1.1662a.3483.3483 0 00.5447.2268l.9587-.6904a9.299 9.299 0 00.2855.087l.414 1.1066a.3452.3452 0 00.5781.1154l.8079-.8656c.0972.0111.1954.0203.2936.0294l.6236 1.0073a.3472.3472 0 00.5892 0l.6236-1.0073c.0982-.0091.1964-.0183.2936-.0294l.8069.8656a.3483.3483 0 00.578-.1154l.4141-1.1066a8.4626 8.4626 0 00.2855-.087l.9587.6904a.3452.3452 0 00.5447-.2268l.1903-1.1662c.088-.0456.1751-.0931.2622-.1407l1.0762.49a.3472.3472 0 00.49-.327l-.0415-1.1845a6.7267 6.7267 0 00.2267-.1863l1.1531.2713a.3472.3472 0 00.4171-.416l-.2713-1.1542c.0628-.0749.1255-.1508.1863-.2278l1.1845.0415a.3442.3442 0 00.328-.49l-.49-1.076c.0475-.0872.0951-.1742.1407-.2623l1.1662-.1893a.3483.3483 0 00.2258-.5447l-.6904-.9587.087-.2855 1.1066-.414a.3462.3462 0 00.1154-.5781l-.8656-.8079c.0101-.0972.0202-.1954.0283-.2936l1.0073-.6236a.3442.3442 0 000-.5892zm-6.7413 8.3551a.7138.7138 0 01.2986-1.396.714.714 0 11-.2997 1.396zm-.3422-2.3142a.649.649 0 00-.7715.5l-.3573 1.6685c-1.1035.501-2.3285.7795-3.6193.7795a8.7368 8.7368 0 01-3.6951-.814l-.3574-1.6684a.648.648 0 00-.7714-.499l-1.473.3158a8.7216 8.7216 0 01-.7613-.898h7.1676c.081 0 .1356-.0141.1356-.088v-2.536c0-.074-.0536-.0881-.1356-.0881h-2.0966v-1.6077h2.2677c.2065 0 1.1065.0587 1.394 1.2088.0901.3533.2875 1.5044.4232 1.8729.1346.413.6833 1.2381 1.2685 1.2381h3.5716a.7492.7492 0 00.1296-.0131 8.7874 8.7874 0 01-.8119.9526zM6.8369 20.024a.714.714 0 11-.2997-1.396.714.714 0 01.2997 1.396zM4.1177 8.9972a.7137.7137 0 11-1.304.5791.7137.7137 0 011.304-.579zm-.8352 1.9813l1.5347-.6824a.65.65 0 00.33-.8585l-.3158-.7147h1.2432v5.6025H3.5669a8.7753 8.7753 0 01-.2834-3.348zm6.7343-.5437V8.7836h2.9601c.153 0 1.0792.1772 1.0792.8697 0 .575-.7107.7815-1.2948.7815zm10.7574 1.4862c0 .2187-.008.4363-.0243.651h-.9c-.09 0-.1265.0586-.1265.1477v.413c0 .973-.5487 1.1846-1.0296 1.2382-.4576.0517-.9648-.1913-1.0275-.4717-.2704-1.5186-.7198-1.8436-1.4305-2.4034.8817-.5599 1.799-1.386 1.799-2.4915 0-1.1936-.819-1.9458-1.3769-2.3153-.7825-.5163-1.6491-.6195-1.883-.6195H5.4682a8.7651 8.7651 0 014.907-2.7699l1.0974 1.151a.648.648 0 00.9182.0213l1.227-1.1743a8.7753 8.7753 0 016.0044 4.2762l-.8403 1.8982a.652.652 0 00.33.8585l1.6178.7188c.0283.2875.0425.577.0425.8717zm-9.3006-9.5993a.7128.7128 0 11.984 1.0316.7137.7137 0 01-.984-1.0316zm8.3389 6.71a.7107.7107 0 01.9395-.3625.7137.7137 0 11-.9405.3635z"; + + page.drawText("Simple Icons: Rust (24x24):", { x: 280, y: 230, size: 11, color: black }); + page.drawSvgPath(siRust, { + x: 280, + y: 220, + scale: 2, + color: grayscale(0.15), + }); + + // Note + page.drawRectangle({ + x: 50, + y: 60, + width: 500, + height: 60, + color: rgb(0.95, 0.95, 0.95), + borderColor: grayscale(0.7), + borderWidth: 0.5, + }); + page.drawText("Icons from open source libraries with permissive licenses:", { + x: 60, + y: 105, + size: 10, + color: black, + }); + page.drawText("Simple Icons (CC0): GitHub, TypeScript, npm, Docker, Bun, Node.js, Rust", { + x: 60, + y: 90, + size: 9, + color: grayscale(0.4), + }); + page.drawText("Lucide (MIT): Heart, Star, User, Mail, Home, Settings, Search, Bell, File", { + x: 60, + y: 75, + size: 9, + color: grayscale(0.4), + }); + + const bytes = await pdf.save(); + expect(isPdfHeader(bytes)).toBe(true); + await saveTestOutput("drawing/svg-icons-complex.pdf", bytes); + }); + + it("tiles a large sewing pattern across multiple pages", async () => { + // This test demonstrates the use case from Reddit: + // "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." + // + // With libpdf, you can directly render the SVG path to multiple pages without + // the intermediate canvas step. + + const pdf = PDF.create(); + + // A large sewing pattern - this would typically come from your SVG file + // This is a simplified dress/shirt pattern piece with: + // - Curved neckline, armhole, and hem + // - Notches for alignment + // - Grain line indicator + // Pattern is designed at ~800x1200 points (roughly A3 size) + const patternWidth = 800; + const patternHeight = 1200; + + // Main pattern piece outline (bodice front) + const patternOutline = ` + M 100 50 + L 100 100 + Q 80 150 100 200 + L 100 1100 + Q 150 1150 400 1150 + Q 650 1150 700 1100 + L 700 200 + Q 720 150 700 100 + L 700 50 + Q 600 0 400 0 + Q 200 0 100 50 + Z + `; + + // Neckline curve (cut out) + const neckline = ` + M 250 50 + Q 300 120 400 120 + Q 500 120 550 50 + `; + + // Left armhole + const leftArmhole = ` + M 100 200 + Q 50 300 80 400 + Q 100 450 100 500 + `; + + // Right armhole + const rightArmhole = ` + M 700 200 + Q 750 300 720 400 + Q 700 450 700 500 + `; + + // Grain line (arrow indicating fabric grain direction) + const grainLine = ` + M 400 300 + L 400 900 + M 380 340 + L 400 300 + L 420 340 + M 380 860 + L 400 900 + L 420 860 + `; + + // Notches for alignment (small triangles) + const notches = ` + M 100 600 L 85 615 L 100 630 + M 700 600 L 715 615 L 700 630 + M 300 1150 L 300 1170 L 320 1150 + M 500 1150 L 500 1170 L 480 1150 + `; + + // Dart markings + const darts = ` + M 250 800 L 300 600 L 350 800 + M 450 800 L 500 600 L 550 800 + `; + + // Seam allowance line (dashed, 15pt inside the edge) + const seamAllowance = ` + M 115 65 + L 115 115 + Q 95 160 115 210 + L 115 1085 + Q 160 1135 400 1135 + Q 640 1135 685 1085 + L 685 210 + Q 705 160 685 115 + L 685 65 + Q 590 15 400 15 + Q 210 15 115 65 + `; + + // Target page size (US Letter) + const pageWidth = 612; // 8.5 inches + const pageHeight = 792; // 11 inches + const margin = 36; // 0.5 inch margin for printer + + // Calculate printable area + const printableWidth = pageWidth - 2 * margin; + const printableHeight = pageHeight - 2 * margin; + + // With overlap, each tile (except the first) covers less new area + // First tile covers printableWidth, subsequent tiles cover (printableWidth - overlap) + // So: patternWidth = printableWidth + (pagesX - 1) * (printableWidth - overlap) + // Solving for pagesX: pagesX = 1 + ceil((patternWidth - printableWidth) / (printableWidth - overlap)) + const overlapAmount = 18; // Will be used later, defined here for calculation + const effectiveTileWidth = printableWidth - overlapAmount; + const effectiveTileHeight = printableHeight - overlapAmount; + + const pagesX = + patternWidth <= printableWidth + ? 1 + : 1 + Math.ceil((patternWidth - printableWidth) / effectiveTileWidth); + const pagesY = + patternHeight <= printableHeight + ? 1 + : 1 + Math.ceil((patternHeight - printableHeight) / effectiveTileHeight); + const totalPages = pagesX * pagesY; + + // Overlap amount - pages overlap by this much when assembled + const overlap = overlapAmount; + + // Helper to draw pattern content on a tile + const drawPatternOnPage = ( + page: ReturnType, + pageCol: number, + pageRow: number, + ) => { + // Draw page info + page.drawText(`Sewing Pattern - Page ${pageRow * pagesX + pageCol + 1} of ${totalPages}`, { + x: margin, + y: pageHeight - 20, + size: 10, + color: grayscale(0.5), + }); + page.drawText(`Tile: Column ${pageCol + 1} of ${pagesX}, Row ${pageRow + 1} of ${pagesY}`, { + x: margin, + y: pageHeight - 32, + size: 8, + color: grayscale(0.6), + }); + + // Draw registration marks for overlapping assembly + // These marks appear in the overlap region so adjacent pages can be aligned + const markSize = 10; + + // Helper to draw a cross mark at a position + const drawCrossMark = (cx: number, cy: number) => { + page.drawLine({ + start: { x: cx - markSize / 2, y: cy }, + end: { x: cx + markSize / 2, y: cy }, + color: black, + thickness: 0.5, + }); + page.drawLine({ + start: { x: cx, y: cy - markSize / 2 }, + end: { x: cx, y: cy + markSize / 2 }, + color: black, + thickness: 0.5, + }); + }; + + // Draw marks on all four edges (in the overlap regions) + // These will align with corresponding marks on adjacent pages + + // Top edge marks (will align with bottom of page above) + if (pageRow > 0) { + drawCrossMark(margin + printableWidth * 0.25, pageHeight - margin); + drawCrossMark(margin + printableWidth * 0.5, pageHeight - margin); + drawCrossMark(margin + printableWidth * 0.75, pageHeight - margin); + } + + // Bottom edge marks (will align with top of page below) + if (pageRow < pagesY - 1) { + drawCrossMark(margin + printableWidth * 0.25, margin); + drawCrossMark(margin + printableWidth * 0.5, margin); + drawCrossMark(margin + printableWidth * 0.75, margin); + } + + // Left edge marks (will align with right of page to the left) + if (pageCol > 0) { + drawCrossMark(margin, margin + printableHeight * 0.25); + drawCrossMark(margin, margin + printableHeight * 0.5); + drawCrossMark(margin, margin + printableHeight * 0.75); + } + + // Right edge marks (will align with left of page to the right) + if (pageCol < pagesX - 1) { + drawCrossMark(pageWidth - margin, margin + printableHeight * 0.25); + drawCrossMark(pageWidth - margin, margin + printableHeight * 0.5); + drawCrossMark(pageWidth - margin, margin + printableHeight * 0.75); + } + + // Draw corner L-marks for outer edges only (the actual paper boundary) + const cornerSize = 15; + + // Top-left corner (only if this is a top-left edge of the assembled pattern) + if (pageCol === 0 && pageRow === 0) { + page.drawLine({ + start: { x: margin, y: pageHeight - margin }, + end: { x: margin + cornerSize, y: pageHeight - margin }, + color: black, + thickness: 0.75, + }); + page.drawLine({ + start: { x: margin, y: pageHeight - margin }, + end: { x: margin, y: pageHeight - margin - cornerSize }, + color: black, + thickness: 0.75, + }); + } + + // Top-right corner + if (pageCol === pagesX - 1 && pageRow === 0) { + page.drawLine({ + start: { x: pageWidth - margin, y: pageHeight - margin }, + end: { x: pageWidth - margin - cornerSize, y: pageHeight - margin }, + color: black, + thickness: 0.75, + }); + page.drawLine({ + start: { x: pageWidth - margin, y: pageHeight - margin }, + end: { x: pageWidth - margin, y: pageHeight - margin - cornerSize }, + color: black, + thickness: 0.75, + }); + } + + // Bottom-left corner + if (pageCol === 0 && pageRow === pagesY - 1) { + page.drawLine({ + start: { x: margin, y: margin }, + end: { x: margin + cornerSize, y: margin }, + color: black, + thickness: 0.75, + }); + page.drawLine({ + start: { x: margin, y: margin }, + end: { x: margin, y: margin + cornerSize }, + color: black, + thickness: 0.75, + }); + } + + // Bottom-right corner + if (pageCol === pagesX - 1 && pageRow === pagesY - 1) { + page.drawLine({ + start: { x: pageWidth - margin, y: margin }, + end: { x: pageWidth - margin - cornerSize, y: margin }, + color: black, + thickness: 0.75, + }); + page.drawLine({ + start: { x: pageWidth - margin, y: margin }, + end: { x: pageWidth - margin, y: margin + cornerSize }, + color: black, + thickness: 0.75, + }); + } + + // Calculate the offset into the SVG pattern for this tile + // pageCol=0 means we show the left portion of the pattern (SVG x starting at 0) + // pageRow=0 means we show the TOP portion of the pattern (SVG y starting at 0) + // + // Since SVG Y increases downward and PDF Y increases upward, we need to: + // 1. Flip the Y coordinates (done by drawSvgPath with flipY: true by default) + // 2. Position so the correct portion of the flipped pattern appears in the printable area + // + // For row 0 (top printed row), we want SVG y=0 to appear at the top of the printable area + // After Y-flip, SVG y=0 becomes PDF y=0, and SVG y=patternHeight becomes PDF y=-patternHeight + // So we need to translate up by patternHeight to get the top at the top + // + // With overlap: each tile (except the first in each direction) starts `overlap` earlier + // to include the overlap region from the previous tile + + const effectiveTileWidth = printableWidth - overlap; + const effectiveTileHeight = printableHeight - overlap; + const svgOffsetX = pageCol * effectiveTileWidth; + const svgOffsetY = pageRow * effectiveTileHeight; + + // Position calculation: + // - Start at the margin (left edge of printable area) + // - Subtract svgOffsetX to shift the pattern left, revealing the correct horizontal portion + const x = margin - svgOffsetX; + + // For Y positioning with flipY=true: + // - The SVG is flipped, so y=0 in SVG becomes the "top" visually + // - We want the top of the printable area (pageHeight - margin) to show SVG y=svgOffsetY + // - drawSvgPath places the SVG origin at (x, y) after flipping + // - After flip, the pattern extends DOWNWARD from y in PDF space + // - So y should be at the top of printable area, adjusted for the row offset + const y = pageHeight - margin + svgOffsetY; + + // Common options for all path draws + const pathOptions = { x, y }; + + // Draw main pattern outline + page.drawSvgPath(patternOutline, { + ...pathOptions, + borderColor: black, + borderWidth: 1.5, + }); + + // Draw neckline + page.drawSvgPath(neckline, { + ...pathOptions, + borderColor: black, + borderWidth: 1.5, + }); + + // Draw armholes + page.drawSvgPath(leftArmhole, { + ...pathOptions, + borderColor: black, + borderWidth: 1.5, + }); + page.drawSvgPath(rightArmhole, { + ...pathOptions, + borderColor: black, + borderWidth: 1.5, + }); + + // Draw grain line + page.drawSvgPath(grainLine, { + ...pathOptions, + borderColor: rgb(0.3, 0.3, 0.3), + borderWidth: 1, + }); + + // Draw notches + page.drawSvgPath(notches, { + ...pathOptions, + borderColor: black, + borderWidth: 1, + }); + + // Draw darts + page.drawSvgPath(darts, { + ...pathOptions, + borderColor: rgb(0.5, 0.5, 0.5), + borderWidth: 0.75, + }); + + // Draw seam allowance + page.drawSvgPath(seamAllowance, { + ...pathOptions, + borderColor: grayscale(0.6), + borderWidth: 0.5, + }); + + // Add text labels - these need manual positioning since they're not SVG paths + // "FRONT" label at SVG coordinates (350, 500) + const frontLabelSvgX = 350; + const frontLabelSvgY = 500; + const frontLabelPdfX = x + frontLabelSvgX; + const frontLabelPdfY = y - frontLabelSvgY; // Subtract because Y is flipped + + if ( + frontLabelPdfX > margin && + frontLabelPdfX < pageWidth - margin - 60 && + frontLabelPdfY > margin && + frontLabelPdfY < pageHeight - margin + ) { + page.drawText("FRONT", { + x: frontLabelPdfX, + y: frontLabelPdfY, + size: 24, + color: grayscale(0.4), + }); + } + + // "Cut 2 on fold" at SVG coordinates (350, 700) + const cutLabelSvgY = 700; + const cutLabelPdfY = y - cutLabelSvgY; + + if ( + frontLabelPdfX > margin && + frontLabelPdfX < pageWidth - margin - 80 && + cutLabelPdfY > margin && + cutLabelPdfY < pageHeight - margin + ) { + page.drawText("Cut 2 on fold", { + x: frontLabelPdfX, + y: cutLabelPdfY, + size: 12, + color: grayscale(0.5), + }); + } + }; + + // Generate all pages + for (let row = 0; row < pagesY; row++) { + for (let col = 0; col < pagesX; col++) { + const page = pdf.addPage({ size: "letter" }); + drawPatternOnPage(page, col, row); + } + } + + // Add an assembly guide as the last page + const guidePage = pdf.addPage({ size: "letter" }); + guidePage.drawText("Assembly Guide", { + x: 50, + y: 750, + size: 24, + color: black, + }); + guidePage.drawLine({ + start: { x: 50, y: 740 }, + end: { x: 300, y: 740 }, + color: grayscale(0.5), + }); + + guidePage.drawText("1. Print all pages at 100% scale (no scaling)", { + x: 50, + y: 700, + size: 12, + color: black, + }); + guidePage.drawText("2. Cut along the outer edges of each page", { + x: 50, + y: 680, + size: 12, + color: black, + }); + guidePage.drawText("3. Align corner marks between adjacent pages", { + x: 50, + y: 660, + size: 12, + color: black, + }); + guidePage.drawText("4. Tape pages together to form complete pattern", { + x: 50, + y: 640, + size: 12, + color: black, + }); + + // Draw a mini layout diagram + guidePage.drawText("Page Layout:", { x: 50, y: 580, size: 14, color: black }); + + // Scale diagram to fit nicely on the page + // Each cell is roughly 100x130 points (scaled down from 540x720) + const diagramScale = 0.18; + const diagramX = 50; + const diagramY = 350; + const cellWidth = printableWidth * diagramScale; + const cellHeight = printableHeight * diagramScale; + const cellGap = 5; + + for (let row = 0; row < pagesY; row++) { + for (let col = 0; col < pagesX; col++) { + const x = diagramX + col * (cellWidth + cellGap); + const y = diagramY + (pagesY - 1 - row) * (cellHeight + cellGap); + + guidePage.drawRectangle({ + x, + y, + width: cellWidth, + height: cellHeight, + borderColor: black, + borderWidth: 1, + color: rgb(0.95, 0.95, 1), + }); + + guidePage.drawText(`${row * pagesX + col + 1}`, { + x: x + cellWidth / 2 - 5, + y: y + cellHeight / 2 - 5, + size: 14, + color: black, + }); + } + } + + // Draw the pattern outline scaled down on the guide + const miniPatternScale = 0.2; + guidePage.drawSvgPath(patternOutline, { + x: 300, + y: 580, + scale: miniPatternScale, + borderColor: black, + borderWidth: 1, + }); + guidePage.drawText("Pattern Preview", { x: 300, y: 590, size: 10, color: grayscale(0.5) }); + + const bytes = await pdf.save(); + expect(isPdfHeader(bytes)).toBe(true); + expect(pdf.getPageCount()).toBe(totalPages + 1); // Pattern pages + guide + await saveTestOutput("drawing/sewing-pattern-tiled.pdf", bytes); + }); +}); diff --git a/src/api/drawing/types.ts b/src/api/drawing/types.ts index 3ce9bb3..1cc3cce 100644 --- a/src/api/drawing/types.ts +++ b/src/api/drawing/types.ts @@ -289,6 +289,74 @@ export interface DrawEllipseOptions { rotate?: Rotation; } +// ───────────────────────────────────────────────────────────────────────────── +// SVG Path Options +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Options for drawing an SVG path. + * + * SVG paths are automatically transformed from SVG coordinate space + * (Y-down, origin at top-left) to PDF coordinate space (Y-up, origin + * at bottom-left). Use `x`, `y`, and `scale` to position and size + * the path on the page. + */ +export interface DrawSvgPathOptions { + /** + * X position on the page (left edge of the path's bounding box). + * @default 0 + */ + x?: number; + + /** + * Y position on the page (bottom edge of the path's bounding box after transform). + * @default 0 + */ + y?: number; + + /** + * Scale factor to apply to the path. + * Useful for SVG icons with large viewBox (e.g., 512x512). + * A scale of 0.1 would make a 512-unit icon ~51 points. + * @default 1 + */ + scale?: number; + + /** + * Whether to flip the Y-axis to convert from SVG coordinates (Y-down) + * to PDF coordinates (Y-up). + * + * Set to `true` (default) when using SVG paths from icon libraries. + * Set to `false` when using paths already in PDF coordinate space. + * + * @default true + */ + flipY?: boolean; + + /** Fill color (default: black; omit to stroke only if borderColor set) */ + color?: Color; + /** Stroke color (omit for no stroke) */ + borderColor?: Color; + /** Stroke width in points (default: 1 if borderColor set) */ + borderWidth?: number; + /** Line cap style */ + lineCap?: LineCap; + /** Line join style */ + lineJoin?: LineJoin; + /** Miter limit for miter joins */ + miterLimit?: number; + /** Dash pattern array */ + dashArray?: number[]; + /** Dash pattern phase */ + dashPhase?: number; + /** Fill opacity 0-1 (default: 1) */ + opacity?: number; + /** Stroke opacity 0-1 (default: 1) */ + borderOpacity?: number; + /** Winding rule for fill (default: "nonzero") */ + windingRule?: "nonzero" | "evenodd"; +} + // ───────────────────────────────────────────────────────────────────────────── // Path Options // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/api/pdf-page.ts b/src/api/pdf-page.ts index 9a9d058..d0b03cd 100644 --- a/src/api/pdf-page.ts +++ b/src/api/pdf-page.ts @@ -112,6 +112,7 @@ import type { DrawImageOptions, DrawLineOptions, DrawRectangleOptions, + DrawSvgPathOptions, DrawTextOptions, FontInput, Rotation, @@ -1331,6 +1332,84 @@ export class PDFPage { ); } + /** + * Draw an SVG path on the page. + * + * This is a convenience method that parses an SVG path `d` attribute string + * and draws it with the specified options. For more control, use `drawPath()` + * with `appendSvgPath()`. + * + * By default, the path is filled with black. Specify `borderColor` without + * `color` to stroke without filling. + * + * SVG paths are automatically transformed from SVG coordinate space (Y-down) + * to PDF coordinate space (Y-up). Use `x`, `y` to position the path, and + * `scale` to resize it. + * + * @param pathData - SVG path `d` attribute string + * @param options - Drawing options (x, y, scale, color, etc.) + * + * @example + * ```typescript + * // Draw a Font Awesome heart icon at position (100, 500) + * // Icon is 512x512 in SVG, scale to ~50pt + * page.drawSvgPath(faHeartPath, { + * x: 100, + * y: 500, + * scale: 0.1, + * color: rgb(1, 0, 0), + * }); + * + * // Draw a simple triangle at default position (0, 0) + * page.drawSvgPath("M 0 0 L 50 0 L 25 40 Z", { + * color: rgb(0, 0, 1), + * }); + * + * // Stroke a curve + * page.drawSvgPath("M 0 0 C 10 10, 30 10, 40 0", { + * x: 200, + * y: 300, + * borderColor: rgb(0, 0, 0), + * borderWidth: 2, + * }); + * ``` + */ + drawSvgPath(pathData: string, options: DrawSvgPathOptions = {}): void { + const x = options.x ?? 0; + const y = options.y ?? 0; + const scale = options.scale ?? 1; + const flipY = options.flipY ?? true; + + // Execute the SVG path with transform: scale, optionally flip Y, translate to position + const builder = this.drawPath(); + builder.appendSvgPath(pathData, { + flipY, + scale, + translateX: x, + translateY: y, + }); + + // Determine if we should fill, stroke, or both + const hasFill = options.color !== undefined; + const hasStroke = options.borderColor !== undefined; + + if (hasFill && hasStroke) { + builder.fillAndStroke(options); + + return; + } + + if (hasStroke) { + builder.stroke(options); + + return; + } + + // Default: fill with black if no color specified + const fillOptions = hasFill ? options : { ...options, color: black }; + builder.fill(fillOptions); + } + // ───────────────────────────────────────────────────────────────────────────── // Annotations // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/index.ts b/src/index.ts index bc8bb42..368e055 100644 --- a/src/index.ts +++ b/src/index.ts @@ -170,6 +170,7 @@ export { type DrawImageOptions, type DrawLineOptions, type DrawRectangleOptions, + type DrawSvgPathOptions, type DrawTextOptions, type FontInput, type LayoutResult, diff --git a/src/svg/arc-to-bezier.test.ts b/src/svg/arc-to-bezier.test.ts new file mode 100644 index 0000000..a5ac1b3 --- /dev/null +++ b/src/svg/arc-to-bezier.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, it } from "vitest"; + +import { arcToBezier, type ArcEndpoint } from "./arc-to-bezier"; + +describe("arcToBezier", () => { + /** + * Helper to check if a point is approximately on the ellipse. + * Ellipse equation: ((x-cx)/rx)^2 + ((y-cy)/ry)^2 = 1 + */ + function isOnEllipse( + x: number, + y: number, + cx: number, + cy: number, + rx: number, + ry: number, + tolerance = 0.01, + ): boolean { + const dx = (x - cx) / rx; + const dy = (y - cy) / ry; + + return Math.abs(dx * dx + dy * dy - 1) < tolerance; + } + + describe("degenerate cases", () => { + it("returns empty array when start equals end", () => { + const arc: ArcEndpoint = { + x1: 50, + y1: 50, + rx: 25, + ry: 25, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: true, + x2: 50, + y2: 50, + }; + + const curves = arcToBezier(arc); + + expect(curves).toEqual([]); + }); + + it("returns line segment when rx is 0", () => { + const arc: ArcEndpoint = { + x1: 0, + y1: 0, + rx: 0, + ry: 25, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: true, + x2: 50, + y2: 50, + }; + + const curves = arcToBezier(arc); + + expect(curves).toHaveLength(1); + expect(curves[0]).toEqual({ + cp1x: 0, + cp1y: 0, + cp2x: 50, + cp2y: 50, + x: 50, + y: 50, + }); + }); + + it("returns line segment when ry is 0", () => { + const arc: ArcEndpoint = { + x1: 0, + y1: 0, + rx: 25, + ry: 0, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: true, + x2: 50, + y2: 50, + }; + + const curves = arcToBezier(arc); + + expect(curves).toHaveLength(1); + expect(curves[0].x).toBe(50); + expect(curves[0].y).toBe(50); + }); + }); + + describe("simple circular arcs", () => { + it("converts a 90-degree arc", () => { + // Quarter circle: from (100, 50) to (50, 100) with radius 50, center at (50, 50) + const arc: ArcEndpoint = { + x1: 100, + y1: 50, + rx: 50, + ry: 50, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: true, + x2: 50, + y2: 100, + }; + + const curves = arcToBezier(arc); + + // Should produce 1 bezier curve for a 90-degree arc + expect(curves).toHaveLength(1); + + // End point should match + expect(curves[0].x).toBeCloseTo(50, 5); + expect(curves[0].y).toBeCloseTo(100, 5); + }); + + it("converts a 180-degree arc", () => { + // Semi-circle: from (100, 50) to (0, 50) with radius 50 + const arc: ArcEndpoint = { + x1: 100, + y1: 50, + rx: 50, + ry: 50, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: true, + x2: 0, + y2: 50, + }; + + const curves = arcToBezier(arc); + + // Should produce 2 bezier curves for a 180-degree arc + expect(curves).toHaveLength(2); + + // End point should match + const lastCurve = curves[curves.length - 1]; + expect(lastCurve.x).toBeCloseTo(0, 5); + expect(lastCurve.y).toBeCloseTo(50, 5); + }); + + it("converts a full circle (360-degree arc equivalent)", () => { + // Almost full circle - can't do exactly 360 degrees with same start/end + const arc: ArcEndpoint = { + x1: 100, + y1: 50, + rx: 50, + ry: 50, + xAxisRotation: 0, + largeArcFlag: true, + sweepFlag: true, + x2: 99.99, + y2: 50.1, + }; + + const curves = arcToBezier(arc); + + // Should produce 4 bezier curves for an almost full circle + expect(curves.length).toBeGreaterThanOrEqual(4); + }); + }); + + describe("arc flags", () => { + it("large-arc-flag selects the larger arc", () => { + // Two arcs connect the same points - large-arc-flag selects the longer one + const arcSmall: ArcEndpoint = { + x1: 100, + y1: 50, + rx: 50, + ry: 50, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: true, + x2: 50, + y2: 100, + }; + + const arcLarge: ArcEndpoint = { + ...arcSmall, + largeArcFlag: true, + }; + + const curvesSmall = arcToBezier(arcSmall); + const curvesLarge = arcToBezier(arcLarge); + + // Large arc should produce more bezier segments + expect(curvesLarge.length).toBeGreaterThan(curvesSmall.length); + }); + + it("sweep-flag controls arc direction", () => { + const arcCW: ArcEndpoint = { + x1: 100, + y1: 50, + rx: 50, + ry: 50, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: true, + x2: 50, + y2: 0, + }; + + const arcCCW: ArcEndpoint = { + ...arcCW, + sweepFlag: false, + }; + + const curvesCW = arcToBezier(arcCW); + const curvesCCW = arcToBezier(arcCCW); + + // Both should end at the same point but take different paths + expect(curvesCW[curvesCW.length - 1].x).toBeCloseTo(50, 5); + expect(curvesCCW[curvesCCW.length - 1].x).toBeCloseTo(50, 5); + + // The control points should differ (different arc paths) + // Both arcs may have the same number of segments, but control points differ + const cw1 = curvesCW[0]; + const ccw1 = curvesCCW[0]; + + // At least one control point should be different between the two arcs + const controlPointsDiffer = + Math.abs(cw1.cp1x - ccw1.cp1x) > 1 || + Math.abs(cw1.cp1y - ccw1.cp1y) > 1 || + Math.abs(cw1.cp2x - ccw1.cp2x) > 1 || + Math.abs(cw1.cp2y - ccw1.cp2y) > 1; + + expect(controlPointsDiffer).toBe(true); + }); + }); + + describe("elliptical arcs", () => { + it("converts an elliptical arc with different rx and ry", () => { + const arc: ArcEndpoint = { + x1: 100, + y1: 50, + rx: 50, + ry: 25, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: true, + x2: 50, + y2: 75, + }; + + const curves = arcToBezier(arc); + + expect(curves.length).toBeGreaterThan(0); + + // End point should match + const lastCurve = curves[curves.length - 1]; + expect(lastCurve.x).toBeCloseTo(50, 5); + expect(lastCurve.y).toBeCloseTo(75, 5); + }); + + it("handles rotated ellipse", () => { + const arc: ArcEndpoint = { + x1: 100, + y1: 50, + rx: 50, + ry: 25, + xAxisRotation: 45, // 45 degree rotation + largeArcFlag: false, + sweepFlag: true, + x2: 50, + y2: 100, + }; + + const curves = arcToBezier(arc); + + expect(curves.length).toBeGreaterThan(0); + + // End point should match + const lastCurve = curves[curves.length - 1]; + expect(lastCurve.x).toBeCloseTo(50, 5); + expect(lastCurve.y).toBeCloseTo(100, 5); + }); + }); + + describe("radii correction", () => { + it("scales up radii that are too small to reach endpoint", () => { + // The radii are too small to connect these points + // SVG spec says they should be scaled up + const arc: ArcEndpoint = { + x1: 0, + y1: 0, + rx: 10, // Too small to reach (100, 0) directly + ry: 10, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: true, + x2: 100, + y2: 0, + }; + + const curves = arcToBezier(arc); + + // Should still produce valid curves + expect(curves.length).toBeGreaterThan(0); + + // End point should match + const lastCurve = curves[curves.length - 1]; + expect(lastCurve.x).toBeCloseTo(100, 5); + expect(lastCurve.y).toBeCloseTo(0, 5); + }); + + it("handles negative radii by taking absolute value", () => { + const arc: ArcEndpoint = { + x1: 100, + y1: 50, + rx: -50, // Negative, should be treated as 50 + ry: -50, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: true, + x2: 50, + y2: 100, + }; + + const curves = arcToBezier(arc); + + expect(curves.length).toBeGreaterThan(0); + + // End point should match + const lastCurve = curves[curves.length - 1]; + expect(lastCurve.x).toBeCloseTo(50, 5); + expect(lastCurve.y).toBeCloseTo(100, 5); + }); + }); + + describe("curve quality", () => { + it("midpoints of bezier curves approximate the arc well", () => { + // For a circular arc, we can verify that bezier curve endpoints + // and approximate midpoints lie on the circle + const cx = 50; + const cy = 50; + const r = 50; + + const arc: ArcEndpoint = { + x1: 100, + y1: 50, // Point on circle at 0 degrees + rx: r, + ry: r, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: true, + x2: 50, + y2: 100, // Point on circle at 90 degrees + }; + + const curves = arcToBezier(arc); + + // Start point should be on circle + expect(isOnEllipse(100, 50, cx, cy, r, r)).toBe(true); + + // End point of each curve should be approximately on the circle + for (const curve of curves) { + expect(isOnEllipse(curve.x, curve.y, cx, cy, r, r, 0.1)).toBe(true); + } + }); + }); +}); diff --git a/src/svg/arc-to-bezier.ts b/src/svg/arc-to-bezier.ts new file mode 100644 index 0000000..3a27875 --- /dev/null +++ b/src/svg/arc-to-bezier.ts @@ -0,0 +1,265 @@ +/** + * Arc to Bezier Conversion + * + * Converts SVG elliptical arc commands to cubic bezier curves. + * SVG arcs use endpoint parameterization, which must be converted + * to center parameterization before approximating with beziers. + */ + +/** + * Parameters for an elliptical arc in endpoint form. + */ +export interface ArcEndpoint { + /** Starting X coordinate */ + x1: number; + /** Starting Y coordinate */ + y1: number; + /** X radius */ + rx: number; + /** Y radius */ + ry: number; + /** Rotation of the ellipse in degrees */ + xAxisRotation: number; + /** If true, choose the larger arc (> 180 degrees) */ + largeArcFlag: boolean; + /** If true, arc is drawn in positive angle direction */ + sweepFlag: boolean; + /** Ending X coordinate */ + x2: number; + /** Ending Y coordinate */ + y2: number; +} + +/** + * A cubic bezier curve segment. + */ +export interface BezierCurve { + /** First control point X */ + cp1x: number; + /** First control point Y */ + cp1y: number; + /** Second control point X */ + cp2x: number; + /** Second control point Y */ + cp2y: number; + /** End point X */ + x: number; + /** End point Y */ + y: number; +} + +/** + * Convert an SVG arc to one or more cubic bezier curves. + * + * Handles edge cases according to SVG spec: + * - If rx=0 or ry=0, returns a line to the endpoint + * - If start and end points are the same, returns empty array + * - If radii are too small, they're scaled up automatically + * + * @param arc - Arc parameters in endpoint form + * @returns Array of bezier curves that approximate the arc + */ +export function arcToBezier(arc: ArcEndpoint): BezierCurve[] { + const { x1, y1, x2, y2, xAxisRotation, largeArcFlag, sweepFlag } = arc; + + let { rx, ry } = arc; + + // Handle edge case: same start and end point + if (x1 === x2 && y1 === y2) { + return []; + } + + // Handle edge case: zero radius means line + if (rx === 0 || ry === 0) { + return [ + { + cp1x: x1, + cp1y: y1, + cp2x: x2, + cp2y: y2, + x: x2, + y: y2, + }, + ]; + } + + // Ensure radii are positive + rx = Math.abs(rx); + ry = Math.abs(ry); + + // Convert rotation to radians + const phi = (xAxisRotation * Math.PI) / 180; + const cosPhi = Math.cos(phi); + const sinPhi = Math.sin(phi); + + // Step 1: Compute (x1', y1') - transform to unit circle space + const dx = (x1 - x2) / 2; + const dy = (y1 - y2) / 2; + const x1p = cosPhi * dx + sinPhi * dy; + const y1p = -sinPhi * dx + cosPhi * dy; + + // Step 2: Correct radii if they're too small + // Per SVG spec, if the radii are too small to reach from start to end, + // they should be scaled up uniformly + const lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry); + + if (lambda > 1) { + const sqrtLambda = Math.sqrt(lambda); + + rx = sqrtLambda * rx; + ry = sqrtLambda * ry; + } + + // Step 3: Compute center point (cx', cy') in transformed space + const rx2 = rx * rx; + const ry2 = ry * ry; + const x1p2 = x1p * x1p; + const y1p2 = y1p * y1p; + + let sq = (rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2) / (rx2 * y1p2 + ry2 * x1p2); + + if (sq < 0) { + sq = 0; + } + + const coef = (largeArcFlag === sweepFlag ? -1 : 1) * Math.sqrt(sq); + const cxp = (coef * rx * y1p) / ry; + const cyp = (-coef * ry * x1p) / rx; + + // Step 4: Compute center point (cx, cy) in original space + const midX = (x1 + x2) / 2; + const midY = (y1 + y2) / 2; + const cx = cosPhi * cxp - sinPhi * cyp + midX; + const cy = sinPhi * cxp + cosPhi * cyp + midY; + + // Step 5: Compute angles + const ux = (x1p - cxp) / rx; + const uy = (y1p - cyp) / ry; + const vx = (-x1p - cxp) / rx; + const vy = (-y1p - cyp) / ry; + + // Start angle + const theta1 = angleBetween(1, 0, ux, uy); + + // Delta angle (arc extent) + let dTheta = angleBetween(ux, uy, vx, vy); + + // Adjust delta angle based on sweep flag + if (!sweepFlag && dTheta > 0) { + dTheta -= 2 * Math.PI; + } + + if (sweepFlag && dTheta < 0) { + dTheta += 2 * Math.PI; + } + + // Step 6: Split arc into segments and convert each to bezier + // Use segments no larger than 90 degrees for good approximation + const numSegments = Math.ceil(Math.abs(dTheta) / (Math.PI / 2)); + const segmentAngle = dTheta / numSegments; + + const curves: BezierCurve[] = []; + let currentAngle = theta1; + + for (let i = 0; i < numSegments; i++) { + const nextAngle = currentAngle + segmentAngle; + const curve = arcSegmentToBezier(cx, cy, rx, ry, phi, currentAngle, nextAngle); + + curves.push(curve); + + currentAngle = nextAngle; + } + + return curves; +} + +/** + * Compute the angle between two vectors. + */ +function angleBetween(ux: number, uy: number, vx: number, vy: number): number { + const sign = ux * vy - uy * vx < 0 ? -1 : 1; + const dot = ux * vx + uy * vy; + const magU = Math.sqrt(ux * ux + uy * uy); + const magV = Math.sqrt(vx * vx + vy * vy); + + let cos = dot / (magU * magV); + + // Clamp to handle floating point errors + if (cos < -1) { + cos = -1; + } + + if (cos > 1) { + cos = 1; + } + + return sign * Math.acos(cos); +} + +/** + * Convert a single arc segment (up to 90 degrees) to a cubic bezier. + * + * Uses the standard arc approximation formula: + * For an arc of angle theta centered at origin: + * CP1 = P0 + alpha * tangent at P0 + * CP2 = P1 - alpha * tangent at P1 + * + * Where alpha = (4/3) * tan(theta/4) + */ +function arcSegmentToBezier( + cx: number, + cy: number, + rx: number, + ry: number, + phi: number, + theta1: number, + theta2: number, +): BezierCurve { + const dTheta = theta2 - theta1; + + // Compute alpha for bezier approximation + const t = Math.tan(dTheta / 4); + const alpha = (Math.sin(dTheta) * (Math.sqrt(4 + 3 * t * t) - 1)) / 3; + + const cosPhi = Math.cos(phi); + const sinPhi = Math.sin(phi); + + // Start point on unit circle + const cos1 = Math.cos(theta1); + const sin1 = Math.sin(theta1); + + // End point on unit circle + const cos2 = Math.cos(theta2); + const sin2 = Math.sin(theta2); + + // Transform start point to original space + const p0x = cx + cosPhi * rx * cos1 - sinPhi * ry * sin1; + const p0y = cy + sinPhi * rx * cos1 + cosPhi * ry * sin1; + + // Transform end point to original space + const p1x = cx + cosPhi * rx * cos2 - sinPhi * ry * sin2; + const p1y = cy + sinPhi * rx * cos2 + cosPhi * ry * sin2; + + // Tangent at start (derivative of ellipse at theta1, rotated) + const t0x = -cosPhi * rx * sin1 - sinPhi * ry * cos1; + const t0y = -sinPhi * rx * sin1 + cosPhi * ry * cos1; + + // Tangent at end + const t1x = -cosPhi * rx * sin2 - sinPhi * ry * cos2; + const t1y = -sinPhi * rx * sin2 + cosPhi * ry * cos2; + + // Control points + const cp1x = p0x + alpha * t0x; + const cp1y = p0y + alpha * t0y; + const cp2x = p1x - alpha * t1x; + const cp2y = p1y - alpha * t1y; + + return { + cp1x, + cp1y, + cp2x, + cp2y, + x: p1x, + y: p1y, + }; +} diff --git a/src/svg/index.ts b/src/svg/index.ts new file mode 100644 index 0000000..7f3de87 --- /dev/null +++ b/src/svg/index.ts @@ -0,0 +1,34 @@ +/** + * SVG Path Support + * + * Provides utilities for parsing SVG path `d` attribute strings + * and executing them via a callback interface. + */ + +// Parser +export { + parseSvgPath, + type ArcCommand, + type ClosePathCommand, + type CubicCurveCommand, + type HorizontalLineCommand, + type LineToCommand, + type MoveToCommand, + type QuadraticCurveCommand, + type SmoothCubicCurveCommand, + type SmoothQuadraticCurveCommand, + type SvgPathCommand, + type SvgPathCommandType, + type VerticalLineCommand, +} from "./path-parser"; + +// Executor +export { + executeSvgPath, + executeSvgPathString, + type PathSink, + type SvgPathExecutorOptions, +} from "./path-executor"; + +// Arc to Bezier (for advanced users) +export { arcToBezier, type ArcEndpoint, type BezierCurve } from "./arc-to-bezier"; diff --git a/src/svg/path-executor.test.ts b/src/svg/path-executor.test.ts new file mode 100644 index 0000000..4ad23d4 --- /dev/null +++ b/src/svg/path-executor.test.ts @@ -0,0 +1,442 @@ +import { describe, expect, it, vi } from "vitest"; + +import { executeSvgPath, executeSvgPathString, type PathSink } from "./path-executor"; +import { parseSvgPath } from "./path-parser"; + +describe("executeSvgPath", () => { + function createMockSink() { + return { + moveTo: vi.fn(), + lineTo: vi.fn(), + curveTo: vi.fn(), + quadraticCurveTo: vi.fn(), + close: vi.fn(), + }; + } + + // Use flipY: false for all tests to test raw execution logic + const noFlip = { flipY: false }; + + describe("basic commands", () => { + it("executes moveTo", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 10 20"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.moveTo).toHaveBeenCalledWith(10, 20); + }); + + it("executes lineTo", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 0 0 L 100 200"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.lineTo).toHaveBeenCalledWith(100, 200); + }); + + it("executes horizontal line", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 10 20 H 100"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.lineTo).toHaveBeenCalledWith(100, 20); + }); + + it("executes vertical line", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 10 20 V 100"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.lineTo).toHaveBeenCalledWith(10, 100); + }); + + it("executes cubic bezier", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 0 0 C 10 20 30 40 50 60"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.curveTo).toHaveBeenCalledWith(10, 20, 30, 40, 50, 60); + }); + + it("executes quadratic bezier", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 0 0 Q 50 100 100 0"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.quadraticCurveTo).toHaveBeenCalledWith(50, 100, 100, 0); + }); + + it("executes close path", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 0 0 L 100 0 L 50 100 Z"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.close).toHaveBeenCalled(); + }); + }); + + describe("relative coordinates", () => { + it("converts relative moveTo to absolute", () => { + const sink = createMockSink(); + const commands = parseSvgPath("m 10 20"); + + // When starting from (0, 0), relative is same as absolute + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.moveTo).toHaveBeenCalledWith(10, 20); + }); + + it("applies translation to initial relative move", () => { + const sink = createMockSink(); + const commands = parseSvgPath("m 10 10 l 5 0"); + + executeSvgPath(commands, sink, 0, 0, { flipY: false, translateX: 100, translateY: 200 }); + + expect(sink.moveTo).toHaveBeenCalledWith(110, 210); + expect(sink.lineTo).toHaveBeenCalledWith(115, 210); + }); + + it("converts relative lineTo to absolute", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 100 100 l 50 50"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.lineTo).toHaveBeenCalledWith(150, 150); + }); + + it("converts relative horizontal line to absolute", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 100 50 h 25"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.lineTo).toHaveBeenCalledWith(125, 50); + }); + + it("converts relative vertical line to absolute", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 50 100 v 25"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.lineTo).toHaveBeenCalledWith(50, 125); + }); + + it("converts relative cubic bezier to absolute", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 100 100 c 10 20 30 40 50 60"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.curveTo).toHaveBeenCalledWith(110, 120, 130, 140, 150, 160); + }); + + it("converts relative quadratic bezier to absolute", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 100 100 q 25 50 50 0"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.quadraticCurveTo).toHaveBeenCalledWith(125, 150, 150, 100); + }); + + it("handles chain of relative commands", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 0 0 l 10 10 l 10 10 l 10 10"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.lineTo).toHaveBeenNthCalledWith(1, 10, 10); + expect(sink.lineTo).toHaveBeenNthCalledWith(2, 20, 20); + expect(sink.lineTo).toHaveBeenNthCalledWith(3, 30, 30); + }); + }); + + describe("smooth curves", () => { + it("reflects control point for smooth cubic after C", () => { + const sink = createMockSink(); + // First curve ends with CP2 at (80, 80) and endpoint at (100, 100) + // Smooth curve should reflect CP2 around endpoint: 2*100-80=120, 2*100-80=120 + const commands = parseSvgPath("M 0 0 C 20 20 80 80 100 100 S 180 180 200 200"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + // First curve + expect(sink.curveTo).toHaveBeenNthCalledWith(1, 20, 20, 80, 80, 100, 100); + // Second curve - reflected CP1 is (120, 120) + expect(sink.curveTo).toHaveBeenNthCalledWith(2, 120, 120, 180, 180, 200, 200); + }); + + it("uses current point as CP1 when S not preceded by C", () => { + const sink = createMockSink(); + // When S is not after C/c/S/s, first control point equals current point + const commands = parseSvgPath("M 100 100 S 150 150 200 200"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + // CP1 should equal current point (100, 100) + expect(sink.curveTo).toHaveBeenCalledWith(100, 100, 150, 150, 200, 200); + }); + + it("reflects control point for smooth quadratic after Q", () => { + const sink = createMockSink(); + // First quadratic has CP at (50, 100), endpoint at (100, 0) + // Smooth should reflect: 2*100-50=150, 2*0-100=-100 + const commands = parseSvgPath("M 0 0 Q 50 100 100 0 T 200 0"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.quadraticCurveTo).toHaveBeenNthCalledWith(1, 50, 100, 100, 0); + expect(sink.quadraticCurveTo).toHaveBeenNthCalledWith(2, 150, -100, 200, 0); + }); + + it("uses current point as CP when T not preceded by Q", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 100 100 T 200 200"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + // CP should equal current point (100, 100), making it effectively a line + expect(sink.quadraticCurveTo).toHaveBeenCalledWith(100, 100, 200, 200); + }); + + it("handles chain of smooth cubic curves", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 0 0 C 0 50 50 100 100 100 S 200 100 200 50 S 150 0 100 0"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.curveTo).toHaveBeenCalledTimes(3); + }); + + it("handles relative smooth cubic", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 0 0 C 20 20 80 80 100 100 s 80 80 100 100"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + // Reflected CP1: 2*100-80=120, 2*100-80=120 + // Relative CP2: 100+80=180, 100+80=180 + // Relative endpoint: 100+100=200, 100+100=200 + expect(sink.curveTo).toHaveBeenNthCalledWith(2, 120, 120, 180, 180, 200, 200); + }); + }); + + describe("arc commands", () => { + it("converts arc to cubic bezier curves", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 100 50 A 50 50 0 0 1 50 100"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + // Arc should be converted to at least one bezier curve + expect(sink.curveTo).toHaveBeenCalled(); + }); + + it("handles relative arc", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 100 50 a 50 50 0 0 1 -50 50"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + // Should convert arc ending at (50, 100) relative to start + expect(sink.curveTo).toHaveBeenCalled(); + + // Get the final curve's endpoint + const lastCall = sink.curveTo.mock.calls[sink.curveTo.mock.calls.length - 1]; + expect(lastCall[4]).toBeCloseTo(50, 1); // x + expect(lastCall[5]).toBeCloseTo(100, 1); // y + }); + + it("handles zero-radius arc as degenerate case", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 0 0 A 0 0 0 0 1 50 50"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + // Zero radius arc becomes a line + expect(sink.curveTo).toHaveBeenCalled(); + const call = sink.curveTo.mock.calls[0]; + expect(call[4]).toBe(50); + expect(call[5]).toBe(50); + }); + }); + + describe("close path", () => { + it("returns to subpath start after close", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 100 100 L 200 100 L 200 200 Z L 300 300"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + // After Z, position returns to (100, 100) + // Then L 300 300 draws from (100, 100) to (300, 300) + expect(sink.lineTo).toHaveBeenLastCalledWith(300, 300); + }); + + it("handles multiple subpaths", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 0 0 L 100 0 Z M 200 200 L 300 200 Z"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.moveTo).toHaveBeenCalledTimes(2); + expect(sink.close).toHaveBeenCalledTimes(2); + }); + }); + + describe("initial position", () => { + it("uses default initial position (0, 0)", () => { + const sink = createMockSink(); + const commands = parseSvgPath("l 50 50"); + + executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(sink.lineTo).toHaveBeenCalledWith(50, 50); + }); + + it("respects custom initial position", () => { + const sink = createMockSink(); + const commands = parseSvgPath("l 50 50"); + + executeSvgPath(commands, sink, 100, 100, noFlip); + + expect(sink.lineTo).toHaveBeenCalledWith(150, 150); + }); + + it("uses initial position for relative moveTo", () => { + const sink = createMockSink(); + const commands = parseSvgPath("m 10 20"); + + executeSvgPath(commands, sink, 100, 100, noFlip); + + expect(sink.moveTo).toHaveBeenCalledWith(110, 120); + }); + }); + + describe("return value", () => { + it("returns final position", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 10 20 L 100 200"); + + const result = executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(result).toEqual({ x: 100, y: 200 }); + }); + + it("returns subpath start after close", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 50 50 L 100 100 Z"); + + const result = executeSvgPath(commands, sink, 0, 0, noFlip); + + expect(result).toEqual({ x: 50, y: 50 }); + }); + + it("returns initial position for empty path", () => { + const sink = createMockSink(); + const result = executeSvgPath([], sink, 25, 75, noFlip); + + expect(result).toEqual({ x: 25, y: 75 }); + }); + }); + + describe("executeSvgPathString", () => { + it("parses and executes path string", () => { + const sink = createMockSink(); + + executeSvgPathString("M 10 20 L 100 200", sink, 0, 0, noFlip); + + expect(sink.moveTo).toHaveBeenCalledWith(10, 20); + expect(sink.lineTo).toHaveBeenCalledWith(100, 200); + }); + + it("respects initial position", () => { + const sink = createMockSink(); + + executeSvgPathString("l 50 50", sink, 100, 100, noFlip); + + expect(sink.lineTo).toHaveBeenCalledWith(150, 150); + }); + }); + + describe("complex paths", () => { + it("executes a heart shape", () => { + const sink = createMockSink(); + const path = "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"; + + executeSvgPathString(path, sink, 0, 0, noFlip); + + expect(sink.moveTo).toHaveBeenCalledWith(10, 30); + expect(sink.curveTo).toHaveBeenCalled(); // Arcs converted to beziers + expect(sink.quadraticCurveTo).toHaveBeenCalledTimes(2); + expect(sink.close).toHaveBeenCalled(); + }); + + it("executes a path with mixed absolute and relative commands", () => { + const sink = createMockSink(); + const path = "M 0 0 L 100 0 l 0 100 L 0 100 l 0 -100 Z"; + + executeSvgPathString(path, sink, 0, 0, noFlip); + + expect(sink.moveTo).toHaveBeenCalledWith(0, 0); + expect(sink.lineTo).toHaveBeenNthCalledWith(1, 100, 0); + expect(sink.lineTo).toHaveBeenNthCalledWith(2, 100, 100); + expect(sink.lineTo).toHaveBeenNthCalledWith(3, 0, 100); + expect(sink.lineTo).toHaveBeenNthCalledWith(4, 0, 0); + expect(sink.close).toHaveBeenCalled(); + }); + }); + + describe("Y-axis flip", () => { + it("flips Y coordinates when flipY is true (default)", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 10 20 L 100 200"); + + // Default is flipY: true + executeSvgPath(commands, sink); + + expect(sink.moveTo).toHaveBeenCalledWith(10, -20); + expect(sink.lineTo).toHaveBeenCalledWith(100, -200); + }); + + it("does not flip Y coordinates when flipY is false", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 10 20 L 100 200"); + + executeSvgPath(commands, sink, 0, 0, { flipY: false }); + + expect(sink.moveTo).toHaveBeenCalledWith(10, 20); + expect(sink.lineTo).toHaveBeenCalledWith(100, 200); + }); + + it("flips relative coordinates correctly", () => { + const sink = createMockSink(); + const commands = parseSvgPath("M 0 0 l 50 50"); + + executeSvgPath(commands, sink); + + // With Y flip: 0 + (-50) = -50 + expect(sink.lineTo).toHaveBeenCalledWith(50, -50); + }); + + it("flips arc sweep direction when Y is flipped", () => { + const sink = createMockSink(); + // A simple arc - sweep flag should be inverted when Y is flipped + const commands = parseSvgPath("M 0 0 A 50 50 0 0 1 100 0"); + + executeSvgPath(commands, sink); + + // Arc should complete (be converted to beziers) + expect(sink.curveTo).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/svg/path-executor.ts b/src/svg/path-executor.ts new file mode 100644 index 0000000..9adac61 --- /dev/null +++ b/src/svg/path-executor.ts @@ -0,0 +1,576 @@ +/** + * SVG Path Executor + * + * Executes parsed SVG path commands via a callback interface. + * Handles: + * - Relative to absolute coordinate conversion + * - Smooth curve control point reflection + * - Arc to bezier conversion + * - Y-axis flipping for PDF coordinate system (optional, default: true) + */ + +import { arcToBezier } from "./arc-to-bezier"; +import type { SvgPathCommand } from "./path-parser"; +import { parseSvgPath } from "./path-parser"; + +/** + * Options for SVG path execution. + */ +export interface SvgPathExecutorOptions { + /** + * Flip Y coordinates (negate Y values). + * + * SVG uses a top-left origin with Y increasing downward. + * PDF uses a bottom-left origin with Y increasing upward. + * + * When true (default), Y coordinates are negated to convert + * SVG paths to PDF coordinate space. + * + * @default true + */ + flipY?: boolean; + + /** + * Scale factor to apply to all coordinates. + * @default 1 + */ + scale?: number; + + /** + * X offset to add after scaling and flipping. + * @default 0 + */ + translateX?: number; + + /** + * Y offset to add after scaling and flipping. + * @default 0 + */ + translateY?: number; +} + +/** + * Callback interface for path execution. + * Each method corresponds to a path operation. + */ +export interface PathSink { + /** + * Move to a point (start a new subpath). + */ + moveTo(x: number, y: number): void; + + /** + * Draw a line to a point. + */ + lineTo(x: number, y: number): void; + + /** + * Draw a cubic bezier curve. + */ + curveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void; + + /** + * Draw a quadratic bezier curve. + */ + quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void; + + /** + * Close the current subpath. + */ + close(): void; +} + +/** + * State tracked during path execution. + */ +interface ExecutorState { + /** Current X position (in output space) */ + currentX: number; + /** Current Y position (in output space) */ + currentY: number; + /** Start X of current subpath (for Z command, in output space) */ + subpathStartX: number; + /** Start Y of current subpath (for Z command, in output space) */ + subpathStartY: number; + /** Last control point X (for S/T smooth curves, in output space) */ + lastControlX: number; + /** Last control point Y (for S/T smooth curves, in output space) */ + lastControlY: number; + /** Last command type (to determine if reflection is valid) */ + lastCommand: string | null; + /** Y-axis flip factor: -1 to flip, 1 for no flip */ + yFlip: 1 | -1; + /** Scale factor */ + scale: number; + /** X translation (applied after scale and flip) */ + translateX: number; + /** Y translation (applied after scale and flip) */ + translateY: number; +} + +/** + * Execute SVG path commands via a callback interface. + * + * The executor handles all coordinate transformations and command + * normalization, so the sink receives only absolute coordinates + * and standard path operations. + * + * @param commands - Parsed SVG path commands + * @param sink - Callback interface for path operations + * @param initialX - Initial X coordinate (default: 0) + * @param initialY - Initial Y coordinate (default: 0) + * @param options - Execution options (flipY, etc.) + * @returns Final position {x, y} after executing all commands + */ +export function executeSvgPath( + commands: SvgPathCommand[], + sink: PathSink, + initialX = 0, + initialY = 0, + options: SvgPathExecutorOptions = {}, +): { x: number; y: number } { + const flipY = options.flipY ?? true; + const yFlip = flipY ? -1 : 1; + const scale = options.scale ?? 1; + const translateX = options.translateX ?? 0; + const translateY = options.translateY ?? 0; + + const initialOutputX = initialX + translateX; + const initialOutputY = initialY + translateY; + + const state: ExecutorState = { + currentX: initialOutputX, + currentY: initialOutputY, + subpathStartX: initialOutputX, + subpathStartY: initialOutputY, + lastControlX: initialOutputX, + lastControlY: initialOutputY, + lastCommand: null, + yFlip, + scale, + translateX, + translateY, + }; + + for (const cmd of commands) { + executeCommand(cmd, state, sink); + } + + return { x: state.currentX, y: state.currentY }; +} + +/** + * Transform an SVG coordinate to output space. + * Applies: scale, Y-flip, then translate. + */ +function transformX(x: number, state: ExecutorState): number { + return x * state.scale + state.translateX; +} + +function transformY(y: number, state: ExecutorState): number { + return y * state.yFlip * state.scale + state.translateY; +} + +/** + * Execute a single SVG path command. + */ +function executeCommand(cmd: SvgPathCommand, state: ExecutorState, sink: PathSink): void { + switch (cmd.type) { + case "M": + executeMoveTo(cmd.x, cmd.y, false, state, sink); + break; + case "m": + executeMoveTo(cmd.x, cmd.y, true, state, sink); + break; + + case "L": + executeLineTo(cmd.x, cmd.y, false, state, sink); + break; + case "l": + executeLineTo(cmd.x, cmd.y, true, state, sink); + break; + + case "H": + // Absolute horizontal: transform X, keep current Y (already in output space) + executeHorizontalLine(cmd.x, false, state, sink); + break; + case "h": + // Relative horizontal: add scaled delta to current X + executeHorizontalLine(cmd.x, true, state, sink); + break; + + case "V": + // Absolute vertical: keep current X, transform Y + executeVerticalLine(cmd.y, false, state, sink); + break; + case "v": + // Relative vertical: add scaled & flipped delta to current Y + executeVerticalLine(cmd.y, true, state, sink); + break; + + case "C": + executeCubicCurve(cmd.x1, cmd.y1, cmd.x2, cmd.y2, cmd.x, cmd.y, false, state, sink); + break; + case "c": + executeCubicCurve(cmd.x1, cmd.y1, cmd.x2, cmd.y2, cmd.x, cmd.y, true, state, sink); + break; + + case "S": + executeSmoothCubic(cmd.x2, cmd.y2, cmd.x, cmd.y, false, state, sink); + break; + case "s": + executeSmoothCubic(cmd.x2, cmd.y2, cmd.x, cmd.y, true, state, sink); + break; + + case "Q": + executeQuadratic(cmd.x1, cmd.y1, cmd.x, cmd.y, false, state, sink); + break; + case "q": + executeQuadratic(cmd.x1, cmd.y1, cmd.x, cmd.y, true, state, sink); + break; + + case "T": + executeSmoothQuadratic(cmd.x, cmd.y, false, state, sink); + break; + case "t": + executeSmoothQuadratic(cmd.x, cmd.y, true, state, sink); + break; + + case "A": + executeArc( + cmd.rx, + cmd.ry, + cmd.xAxisRotation, + cmd.largeArcFlag, + cmd.sweepFlag, + cmd.x, + cmd.y, + false, + state, + sink, + ); + break; + case "a": + executeArc( + cmd.rx, + cmd.ry, + cmd.xAxisRotation, + cmd.largeArcFlag, + cmd.sweepFlag, + cmd.x, + cmd.y, + true, + state, + sink, + ); + break; + + case "Z": + case "z": + executeClose(state, sink); + break; + } + + state.lastCommand = cmd.type; +} + +function executeMoveTo( + x: number, + y: number, + relative: boolean, + state: ExecutorState, + sink: PathSink, +): void { + // For relative: add delta to current position (in SVG space, before transform) + // For absolute: use the coordinate directly (in SVG space) + // We track position in OUTPUT space, so we need to work backwards for relative + // Relative movement: apply scale and flip to the delta only + // Absolute: transform the full coordinate + const outX = relative ? state.currentX + x * state.scale : transformX(x, state); + const outY = relative ? state.currentY + y * state.yFlip * state.scale : transformY(y, state); + + sink.moveTo(outX, outY); + + state.currentX = outX; + state.currentY = outY; + state.subpathStartX = outX; + state.subpathStartY = outY; + state.lastControlX = outX; + state.lastControlY = outY; +} + +function executeLineTo( + x: number, + y: number, + relative: boolean, + state: ExecutorState, + sink: PathSink, +): void { + const outX = relative ? state.currentX + x * state.scale : transformX(x, state); + const outY = relative ? state.currentY + y * state.yFlip * state.scale : transformY(y, state); + + sink.lineTo(outX, outY); + + state.currentX = outX; + state.currentY = outY; + state.lastControlX = outX; + state.lastControlY = outY; +} + +function executeHorizontalLine( + x: number, + relative: boolean, + state: ExecutorState, + sink: PathSink, +): void { + const outX = relative ? state.currentX + x * state.scale : transformX(x, state); + + // Y stays the same (already in output space) + sink.lineTo(outX, state.currentY); + + state.currentX = outX; + state.lastControlX = outX; + state.lastControlY = state.currentY; +} + +function executeVerticalLine( + y: number, + relative: boolean, + state: ExecutorState, + sink: PathSink, +): void { + const outY = relative ? state.currentY + y * state.yFlip * state.scale : transformY(y, state); + + // X stays the same (already in output space) + sink.lineTo(state.currentX, outY); + + state.currentY = outY; + state.lastControlX = state.currentX; + state.lastControlY = outY; +} + +function executeCubicCurve( + x1: number, + y1: number, + x2: number, + y2: number, + x: number, + y: number, + relative: boolean, + state: ExecutorState, + sink: PathSink, +): void { + const outX1 = relative ? state.currentX + x1 * state.scale : transformX(x1, state); + const outY1 = relative ? state.currentY + y1 * state.yFlip * state.scale : transformY(y1, state); + const outX2 = relative ? state.currentX + x2 * state.scale : transformX(x2, state); + const outY2 = relative ? state.currentY + y2 * state.yFlip * state.scale : transformY(y2, state); + const outX = relative ? state.currentX + x * state.scale : transformX(x, state); + const outY = relative ? state.currentY + y * state.yFlip * state.scale : transformY(y, state); + + sink.curveTo(outX1, outY1, outX2, outY2, outX, outY); + + state.currentX = outX; + state.currentY = outY; + // Store second control point for smooth curve reflection + state.lastControlX = outX2; + state.lastControlY = outY2; +} + +function executeSmoothCubic( + x2: number, + y2: number, + x: number, + y: number, + relative: boolean, + state: ExecutorState, + sink: PathSink, +): void { + // Reflect the last control point across current point to get first control point + // (already in output space) + let cp1x: number; + let cp1y: number; + + // Only reflect if previous command was a cubic curve + if ( + state.lastCommand === "C" || + state.lastCommand === "c" || + state.lastCommand === "S" || + state.lastCommand === "s" + ) { + cp1x = 2 * state.currentX - state.lastControlX; + cp1y = 2 * state.currentY - state.lastControlY; + } else { + // Otherwise, use current point as first control point + cp1x = state.currentX; + cp1y = state.currentY; + } + + const outX2 = relative ? state.currentX + x2 * state.scale : transformX(x2, state); + const outY2 = relative ? state.currentY + y2 * state.yFlip * state.scale : transformY(y2, state); + const outX = relative ? state.currentX + x * state.scale : transformX(x, state); + const outY = relative ? state.currentY + y * state.yFlip * state.scale : transformY(y, state); + + sink.curveTo(cp1x, cp1y, outX2, outY2, outX, outY); + + state.currentX = outX; + state.currentY = outY; + state.lastControlX = outX2; + state.lastControlY = outY2; +} + +function executeQuadratic( + x1: number, + y1: number, + x: number, + y: number, + relative: boolean, + state: ExecutorState, + sink: PathSink, +): void { + const outCpX = relative ? state.currentX + x1 * state.scale : transformX(x1, state); + const outCpY = relative ? state.currentY + y1 * state.yFlip * state.scale : transformY(y1, state); + const outX = relative ? state.currentX + x * state.scale : transformX(x, state); + const outY = relative ? state.currentY + y * state.yFlip * state.scale : transformY(y, state); + + sink.quadraticCurveTo(outCpX, outCpY, outX, outY); + + state.currentX = outX; + state.currentY = outY; + // Store control point for smooth quadratic reflection + state.lastControlX = outCpX; + state.lastControlY = outCpY; +} + +function executeSmoothQuadratic( + x: number, + y: number, + relative: boolean, + state: ExecutorState, + sink: PathSink, +): void { + // Reflect the last control point across current point (already in output space) + let cpx: number; + let cpy: number; + + // Only reflect if previous command was a quadratic curve + if ( + state.lastCommand === "Q" || + state.lastCommand === "q" || + state.lastCommand === "T" || + state.lastCommand === "t" + ) { + cpx = 2 * state.currentX - state.lastControlX; + cpy = 2 * state.currentY - state.lastControlY; + } else { + // Otherwise, use current point as control point + cpx = state.currentX; + cpy = state.currentY; + } + + const outX = relative ? state.currentX + x * state.scale : transformX(x, state); + const outY = relative ? state.currentY + y * state.yFlip * state.scale : transformY(y, state); + + sink.quadraticCurveTo(cpx, cpy, outX, outY); + + state.currentX = outX; + state.currentY = outY; + state.lastControlX = cpx; + state.lastControlY = cpy; +} + +function executeArc( + rx: number, + ry: number, + xAxisRotation: number, + largeArcFlag: boolean, + sweepFlag: boolean, + x: number, + y: number, + relative: boolean, + state: ExecutorState, + sink: PathSink, +): void { + const outX = relative ? state.currentX + x * state.scale : transformX(x, state); + const outY = relative ? state.currentY + y * state.yFlip * state.scale : transformY(y, state); + + // Scale the radii + const scaledRx = rx * state.scale; + const scaledRy = ry * state.scale; + + // When Y is flipped, we also need to flip the sweep direction + // because the arc sweeps in the opposite direction visually + const effectiveSweepFlag = state.yFlip === -1 ? !sweepFlag : sweepFlag; + + // Convert arc to bezier curves (in output space) + const curves = arcToBezier({ + x1: state.currentX, + y1: state.currentY, + rx: scaledRx, + ry: scaledRy, + xAxisRotation, + largeArcFlag, + sweepFlag: effectiveSweepFlag, + x2: outX, + y2: outY, + }); + + for (const curve of curves) { + sink.curveTo(curve.cp1x, curve.cp1y, curve.cp2x, curve.cp2y, curve.x, curve.y); + } + + state.currentX = outX; + state.currentY = outY; + state.lastControlX = outX; + state.lastControlY = outY; +} + +function executeClose(state: ExecutorState, sink: PathSink): void { + sink.close(); + + // After close, current point returns to subpath start + state.currentX = state.subpathStartX; + state.currentY = state.subpathStartY; + state.lastControlX = state.subpathStartX; + state.lastControlY = state.subpathStartY; +} + +/** + * Parse and execute an SVG path string. + * + * This is a convenience function that combines parsing and execution. + * + * By default, Y coordinates are flipped (negated) to convert from SVG's + * top-left origin to PDF's bottom-left origin. Set `flipY: false` in + * options to disable this behavior. + * + * @param pathData - SVG path d string + * @param sink - Callback interface for path operations + * @param initialX - Initial X coordinate (default: 0) + * @param initialY - Initial Y coordinate (default: 0) + * @param options - Execution options (flipY, etc.) + * @returns Final position {x, y} after executing all commands + * + * @example + * ```typescript + * const sink = { + * moveTo: (x, y) => console.log(`M ${x} ${y}`), + * lineTo: (x, y) => console.log(`L ${x} ${y}`), + * curveTo: (cp1x, cp1y, cp2x, cp2y, x, y) => console.log(`C ...`), + * quadraticCurveTo: (cpx, cpy, x, y) => console.log(`Q ...`), + * close: () => console.log(`Z`), + * }; + * + * executeSvgPathString("M 10 10 L 100 10 L 100 100 Z", sink); + * ``` + */ +export function executeSvgPathString( + pathData: string, + sink: PathSink, + initialX = 0, + initialY = 0, + options: SvgPathExecutorOptions = {}, +): { x: number; y: number } { + const commands = parseSvgPath(pathData); + + return executeSvgPath(commands, sink, initialX, initialY, options); +} diff --git a/src/svg/path-parser.test.ts b/src/svg/path-parser.test.ts new file mode 100644 index 0000000..f558cea --- /dev/null +++ b/src/svg/path-parser.test.ts @@ -0,0 +1,593 @@ +import { describe, expect, it } from "vitest"; + +import { parseSvgPath } from "./path-parser"; + +describe("parseSvgPath", () => { + describe("basic commands", () => { + it("parses moveTo (M)", () => { + const commands = parseSvgPath("M 10 20"); + + expect(commands).toEqual([{ type: "M", x: 10, y: 20 }]); + }); + + it("parses lineTo (L)", () => { + const commands = parseSvgPath("M 0 0 L 100 200"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ type: "L", x: 100, y: 200 }); + }); + + it("parses horizontal line (H)", () => { + const commands = parseSvgPath("M 0 0 H 100"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ type: "H", x: 100 }); + }); + + it("parses vertical line (V)", () => { + const commands = parseSvgPath("M 0 0 V 100"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ type: "V", y: 100 }); + }); + + it("parses cubic bezier (C)", () => { + const commands = parseSvgPath("M 0 0 C 10 20 30 40 50 60"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ + type: "C", + x1: 10, + y1: 20, + x2: 30, + y2: 40, + x: 50, + y: 60, + }); + }); + + it("parses smooth cubic (S)", () => { + const commands = parseSvgPath("M 0 0 S 30 40 50 60"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ + type: "S", + x2: 30, + y2: 40, + x: 50, + y: 60, + }); + }); + + it("parses quadratic bezier (Q)", () => { + const commands = parseSvgPath("M 0 0 Q 50 100 100 0"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ + type: "Q", + x1: 50, + y1: 100, + x: 100, + y: 0, + }); + }); + + it("parses smooth quadratic (T)", () => { + const commands = parseSvgPath("M 0 0 T 100 0"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ type: "T", x: 100, y: 0 }); + }); + + it("parses arc (A)", () => { + const commands = parseSvgPath("M 0 0 A 25 25 0 0 1 50 50"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ + type: "A", + rx: 25, + ry: 25, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: true, + x: 50, + y: 50, + }); + }); + + it("parses arc with large-arc flag", () => { + const commands = parseSvgPath("M 0 0 A 25 25 0 1 0 50 50"); + + expect(commands[1]).toEqual({ + type: "A", + rx: 25, + ry: 25, + xAxisRotation: 0, + largeArcFlag: true, + sweepFlag: false, + x: 50, + y: 50, + }); + }); + + it("parses closePath (Z)", () => { + const commands = parseSvgPath("M 0 0 L 100 0 L 50 100 Z"); + + expect(commands).toHaveLength(4); + expect(commands[3]).toEqual({ type: "Z" }); + }); + }); + + describe("relative commands", () => { + it("parses relative moveTo (m)", () => { + const commands = parseSvgPath("m 10 20"); + + expect(commands).toEqual([{ type: "m", x: 10, y: 20 }]); + }); + + it("parses relative lineTo (l)", () => { + const commands = parseSvgPath("M 0 0 l 100 200"); + + expect(commands[1]).toEqual({ type: "l", x: 100, y: 200 }); + }); + + it("parses relative horizontal line (h)", () => { + const commands = parseSvgPath("M 0 0 h 100"); + + expect(commands[1]).toEqual({ type: "h", x: 100 }); + }); + + it("parses relative vertical line (v)", () => { + const commands = parseSvgPath("M 0 0 v 100"); + + expect(commands[1]).toEqual({ type: "v", y: 100 }); + }); + + it("parses relative cubic bezier (c)", () => { + const commands = parseSvgPath("M 0 0 c 10 20 30 40 50 60"); + + expect(commands[1]).toEqual({ + type: "c", + x1: 10, + y1: 20, + x2: 30, + y2: 40, + x: 50, + y: 60, + }); + }); + + it("parses relative smooth cubic (s)", () => { + const commands = parseSvgPath("M 0 0 s 30 40 50 60"); + + expect(commands[1]).toEqual({ + type: "s", + x2: 30, + y2: 40, + x: 50, + y: 60, + }); + }); + + it("parses relative quadratic bezier (q)", () => { + const commands = parseSvgPath("M 0 0 q 50 100 100 0"); + + expect(commands[1]).toEqual({ + type: "q", + x1: 50, + y1: 100, + x: 100, + y: 0, + }); + }); + + it("parses relative smooth quadratic (t)", () => { + const commands = parseSvgPath("M 0 0 t 100 0"); + + expect(commands[1]).toEqual({ type: "t", x: 100, y: 0 }); + }); + + it("parses relative arc (a)", () => { + const commands = parseSvgPath("M 0 0 a 25 25 0 0 1 50 50"); + + expect(commands[1]).toEqual({ + type: "a", + rx: 25, + ry: 25, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: true, + x: 50, + y: 50, + }); + }); + + it("parses relative closePath (z)", () => { + const commands = parseSvgPath("M 0 0 l 100 0 l -50 100 z"); + + expect(commands[3]).toEqual({ type: "z" }); + }); + }); + + describe("number formats", () => { + it("parses integers", () => { + const commands = parseSvgPath("M 10 20"); + + expect(commands[0]).toEqual({ type: "M", x: 10, y: 20 }); + }); + + it("parses decimals", () => { + const commands = parseSvgPath("M 10.5 20.75"); + + expect(commands[0]).toEqual({ type: "M", x: 10.5, y: 20.75 }); + }); + + it("parses negative numbers", () => { + const commands = parseSvgPath("M -10 -20"); + + expect(commands[0]).toEqual({ type: "M", x: -10, y: -20 }); + }); + + it("parses leading decimals (no leading zero)", () => { + const commands = parseSvgPath("M .5 .75"); + + expect(commands[0]).toEqual({ type: "M", x: 0.5, y: 0.75 }); + }); + + it("parses scientific notation", () => { + const commands = parseSvgPath("M 1e2 2e-3"); + + expect(commands[0]).toEqual({ type: "M", x: 100, y: 0.002 }); + }); + + it("parses scientific notation with uppercase E", () => { + const commands = parseSvgPath("M 1E2 2E+3"); + + expect(commands[0]).toEqual({ type: "M", x: 100, y: 2000 }); + }); + }); + + describe("whitespace and separators", () => { + it("handles spaces between values", () => { + const commands = parseSvgPath("M 10 20 L 30 40"); + + expect(commands).toHaveLength(2); + }); + + it("handles commas between values", () => { + const commands = parseSvgPath("M10,20 L30,40"); + + expect(commands).toHaveLength(2); + expect(commands[0]).toEqual({ type: "M", x: 10, y: 20 }); + expect(commands[1]).toEqual({ type: "L", x: 30, y: 40 }); + }); + + it("handles no separator with negative sign", () => { + const commands = parseSvgPath("M10-20 L30-40"); + + expect(commands).toHaveLength(2); + expect(commands[0]).toEqual({ type: "M", x: 10, y: -20 }); + expect(commands[1]).toEqual({ type: "L", x: 30, y: -40 }); + }); + + it("handles no separator with decimal point", () => { + const commands = parseSvgPath("M.5.5 L1.5.5"); + + expect(commands).toHaveLength(2); + expect(commands[0]).toEqual({ type: "M", x: 0.5, y: 0.5 }); + expect(commands[1]).toEqual({ type: "L", x: 1.5, y: 0.5 }); + }); + + it("handles tabs and newlines", () => { + const commands = parseSvgPath("M\t10\n20\rL\r\n30\t40"); + + expect(commands).toHaveLength(2); + }); + + it("handles no space between command and number", () => { + const commands = parseSvgPath("M10 20L30 40"); + + expect(commands).toHaveLength(2); + }); + }); + + describe("repeated commands", () => { + it("handles multiple coordinate pairs after L", () => { + const commands = parseSvgPath("M 0 0 L 10 10 20 20 30 30"); + + expect(commands).toHaveLength(4); + expect(commands[1]).toEqual({ type: "L", x: 10, y: 10 }); + expect(commands[2]).toEqual({ type: "L", x: 20, y: 20 }); + expect(commands[3]).toEqual({ type: "L", x: 30, y: 30 }); + }); + + it("converts implicit commands after M to L", () => { + // After M, subsequent coordinate pairs become L commands + const commands = parseSvgPath("M 10 10 20 20 30 30"); + + expect(commands).toHaveLength(3); + expect(commands[0]).toEqual({ type: "M", x: 10, y: 10 }); + expect(commands[1]).toEqual({ type: "L", x: 20, y: 20 }); + expect(commands[2]).toEqual({ type: "L", x: 30, y: 30 }); + }); + + it("converts implicit commands after m to l", () => { + // After m, subsequent coordinate pairs become l commands + const commands = parseSvgPath("m 10 10 20 20 30 30"); + + expect(commands).toHaveLength(3); + expect(commands[0]).toEqual({ type: "m", x: 10, y: 10 }); + expect(commands[1]).toEqual({ type: "l", x: 20, y: 20 }); + expect(commands[2]).toEqual({ type: "l", x: 30, y: 30 }); + }); + + it("handles multiple cubic bezier curves", () => { + const commands = parseSvgPath("M 0 0 C 1 2 3 4 5 6 7 8 9 10 11 12"); + + expect(commands).toHaveLength(3); + expect(commands[1]).toEqual({ + type: "C", + x1: 1, + y1: 2, + x2: 3, + y2: 4, + x: 5, + y: 6, + }); + expect(commands[2]).toEqual({ + type: "C", + x1: 7, + y1: 8, + x2: 9, + y2: 10, + x: 11, + y: 12, + }); + }); + }); + + describe("complex paths", () => { + it("parses a triangle", () => { + const commands = parseSvgPath("M 0 0 L 100 0 L 50 100 Z"); + + expect(commands).toHaveLength(4); + }); + + it("parses a rectangle with close", () => { + const commands = parseSvgPath("M 0 0 H 100 V 50 H 0 Z"); + + expect(commands).toHaveLength(5); + }); + + it("parses a heart shape", () => { + const path = "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"; + const commands = parseSvgPath(path); + + expect(commands).toHaveLength(6); + expect(commands[0].type).toBe("M"); + expect(commands[1].type).toBe("A"); + expect(commands[2].type).toBe("A"); + expect(commands[3].type).toBe("Q"); + expect(commands[4].type).toBe("Q"); + expect(commands[5].type).toBe("Z"); + }); + + it("parses multiple subpaths", () => { + const path = "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"; + const commands = parseSvgPath(path); + + expect(commands).toHaveLength(10); + // First subpath + expect(commands[0]).toEqual({ type: "M", x: 0, y: 0 }); + expect(commands[4]).toEqual({ type: "Z" }); + // Second subpath + expect(commands[5]).toEqual({ type: "M", x: 25, y: 25 }); + expect(commands[9]).toEqual({ type: "Z" }); + }); + }); + + describe("arc flag edge cases", () => { + // SVG spec allows arc flags (large-arc-flag and sweep-flag) to be written as + // single digits without separators. For example: "a1 1 0 00.5.5" has flags 0,0 + // followed by x=0.5, y=0.5. + + it("parses flags 00 followed by decimal coordinates", () => { + // a rx ry rotation large-arc-flag sweep-flag x y + // a 1 1 0 0 0 .5 .5 + const commands = parseSvgPath("M0 0 a1 1 0 00.5.5"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ + type: "a", + rx: 1, + ry: 1, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: false, + x: 0.5, + y: 0.5, + }); + }); + + it("parses flags 01 followed by decimal coordinates", () => { + const commands = parseSvgPath("M0 0 a1 1 0 01.5.5"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ + type: "a", + rx: 1, + ry: 1, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: true, + x: 0.5, + y: 0.5, + }); + }); + + it("parses flags 10 followed by decimal coordinates", () => { + const commands = parseSvgPath("M0 0 a1 1 0 10.5.5"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ + type: "a", + rx: 1, + ry: 1, + xAxisRotation: 0, + largeArcFlag: true, + sweepFlag: false, + x: 0.5, + y: 0.5, + }); + }); + + it("parses flags 11 followed by decimal coordinates", () => { + const commands = parseSvgPath("M0 0 a1 1 0 11.5.5"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ + type: "a", + rx: 1, + ry: 1, + xAxisRotation: 0, + largeArcFlag: true, + sweepFlag: true, + x: 0.5, + y: 0.5, + }); + }); + + it("parses flags followed by negative coordinates", () => { + const commands = parseSvgPath("M0 0 a1 1 0 00-5-5"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ + type: "a", + rx: 1, + ry: 1, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: false, + x: -5, + y: -5, + }); + }); + + it("parses flags followed by integer coordinates", () => { + const commands = parseSvgPath("M0 0 a1 1 0 0050 50"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ + type: "a", + rx: 1, + ry: 1, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: false, + x: 50, + y: 50, + }); + }); + + it("parses multiple arcs with compact flag notation", () => { + // Two arcs: first with flags 0,0 and second with flags 1,1 + const commands = parseSvgPath("M0 0 a1 1 0 00.5.5 1 1 0 11.5.5"); + + expect(commands).toHaveLength(3); + expect(commands[1]).toEqual({ + type: "a", + rx: 1, + ry: 1, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: false, + x: 0.5, + y: 0.5, + }); + expect(commands[2]).toEqual({ + type: "a", + rx: 1, + ry: 1, + xAxisRotation: 0, + largeArcFlag: true, + sweepFlag: true, + x: 0.5, + y: 0.5, + }); + }); + + it("parses absolute arc with compact flag notation", () => { + const commands = parseSvgPath("M0 0 A1 1 0 00.5.5"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ + type: "A", + rx: 1, + ry: 1, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: false, + x: 0.5, + y: 0.5, + }); + }); + + it("parses real-world icon path with compact flags (Docker containers)", () => { + // Simplified from Docker logo - boxes that use arc-like compact notation patterns + const commands = parseSvgPath("M0 0a.186.186 0 00.186-.185"); + + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual({ + type: "a", + rx: 0.186, + ry: 0.186, + xAxisRotation: 0, + largeArcFlag: false, + sweepFlag: false, + x: 0.186, + y: -0.185, + }); + }); + }); + + describe("edge cases", () => { + it("skips unexpected characters between parameters", () => { + const commands = parseSvgPath("M 0 0 X 10 10"); + + expect(commands).toEqual([ + { type: "M", x: 0, y: 0 }, + { type: "L", x: 10, y: 10 }, + ]); + }); + + it("returns empty array for empty string", () => { + const commands = parseSvgPath(""); + + expect(commands).toEqual([]); + }); + + it("returns empty array for whitespace only", () => { + const commands = parseSvgPath(" \t\n "); + + expect(commands).toEqual([]); + }); + + it("handles path starting with whitespace", () => { + const commands = parseSvgPath(" M 10 20"); + + expect(commands).toHaveLength(1); + }); + + it("handles path with trailing whitespace", () => { + const commands = parseSvgPath("M 10 20 "); + + expect(commands).toHaveLength(1); + }); + + it("handles Z after M (valid but draws nothing)", () => { + const commands = parseSvgPath("M 10 20 Z"); + + expect(commands).toHaveLength(2); + }); + }); +}); diff --git a/src/svg/path-parser.ts b/src/svg/path-parser.ts new file mode 100644 index 0000000..64c2727 --- /dev/null +++ b/src/svg/path-parser.ts @@ -0,0 +1,541 @@ +/** + * SVG Path Parser + * + * Parses SVG path `d` attribute strings into command objects. + * Handles all SVG path commands: M, L, H, V, C, S, Q, T, A, Z + * in both absolute (uppercase) and relative (lowercase) forms. + */ + +/** + * SVG path command types. + */ +export type SvgPathCommandType = + | "M" + | "m" + | "L" + | "l" + | "H" + | "h" + | "V" + | "v" + | "C" + | "c" + | "S" + | "s" + | "Q" + | "q" + | "T" + | "t" + | "A" + | "a" + | "Z" + | "z"; + +/** + * Base interface for all path commands. + */ +interface SvgPathCommandBase { + type: SvgPathCommandType; +} + +/** + * Move to command (M/m). + */ +export interface MoveToCommand extends SvgPathCommandBase { + type: "M" | "m"; + x: number; + y: number; +} + +/** + * Line to command (L/l). + */ +export interface LineToCommand extends SvgPathCommandBase { + type: "L" | "l"; + x: number; + y: number; +} + +/** + * Horizontal line command (H/h). + */ +export interface HorizontalLineCommand extends SvgPathCommandBase { + type: "H" | "h"; + x: number; +} + +/** + * Vertical line command (V/v). + */ +export interface VerticalLineCommand extends SvgPathCommandBase { + type: "V" | "v"; + y: number; +} + +/** + * Cubic bezier curve command (C/c). + */ +export interface CubicCurveCommand extends SvgPathCommandBase { + type: "C" | "c"; + x1: number; + y1: number; + x2: number; + y2: number; + x: number; + y: number; +} + +/** + * Smooth cubic bezier curve command (S/s). + */ +export interface SmoothCubicCurveCommand extends SvgPathCommandBase { + type: "S" | "s"; + x2: number; + y2: number; + x: number; + y: number; +} + +/** + * Quadratic bezier curve command (Q/q). + */ +export interface QuadraticCurveCommand extends SvgPathCommandBase { + type: "Q" | "q"; + x1: number; + y1: number; + x: number; + y: number; +} + +/** + * Smooth quadratic bezier curve command (T/t). + */ +export interface SmoothQuadraticCurveCommand extends SvgPathCommandBase { + type: "T" | "t"; + x: number; + y: number; +} + +/** + * Elliptical arc command (A/a). + */ +export interface ArcCommand extends SvgPathCommandBase { + type: "A" | "a"; + rx: number; + ry: number; + xAxisRotation: number; + largeArcFlag: boolean; + sweepFlag: boolean; + x: number; + y: number; +} + +/** + * Close path command (Z/z). + */ +export interface ClosePathCommand extends SvgPathCommandBase { + type: "Z" | "z"; +} + +/** + * Union type for all SVG path commands. + */ +export type SvgPathCommand = + | MoveToCommand + | LineToCommand + | HorizontalLineCommand + | VerticalLineCommand + | CubicCurveCommand + | SmoothCubicCurveCommand + | QuadraticCurveCommand + | SmoothQuadraticCurveCommand + | ArcCommand + | ClosePathCommand; + +/** + * Number of parameters required for each command type. + */ +const COMMAND_PARAMS: Record = { + M: 2, + m: 2, + L: 2, + l: 2, + H: 1, + h: 1, + V: 1, + v: 1, + C: 6, + c: 6, + S: 4, + s: 4, + Q: 4, + q: 4, + T: 2, + t: 2, + A: 7, + a: 7, + Z: 0, + z: 0, +}; + +/** + * Check if a character is a command letter. + */ +function isCommandLetter(char: string): char is SvgPathCommandType { + return /^[MmLlHhVvCcSsQqTtAaZz]$/.test(char); +} + +/** + * Check if a character can start a number. + */ +function isNumberStart(char: string): boolean { + return /^[0-9.+-]$/.test(char); +} + +/** + * Tokenizer for SVG path strings. + * Extracts command letters and numbers from the path string. + */ +class PathTokenizer { + private readonly path: string; + private pos = 0; + + constructor(path: string) { + this.path = path; + } + + /** + * Skip whitespace and commas. + */ + private skipWhitespaceAndCommas(): void { + while (this.pos < this.path.length) { + const char = this.path[this.pos]; + + if (char === " " || char === "\t" || char === "\n" || char === "\r" || char === ",") { + this.pos++; + } else { + break; + } + } + } + + /** + * Read a number from the current position. + * Handles integers, decimals, negative numbers, and scientific notation. + */ + readNumber(): number | null { + this.skipWhitespaceAndCommas(); + + if (this.pos >= this.path.length) { + return null; + } + + const char = this.path[this.pos]; + + // Check if this is a command letter (not a number) + if (isCommandLetter(char)) { + return null; + } + + if (!isNumberStart(char)) { + this.pos++; + + return null; + } + + // Start building the number string + let numStr = ""; + let hasDecimal = false; + let hasExponent = false; + + // Handle leading sign + if (char === "+" || char === "-") { + numStr += char; + this.pos++; + } + + // Read digits and decimal point + while (this.pos < this.path.length) { + const c = this.path[this.pos]; + + if (c >= "0" && c <= "9") { + numStr += c; + this.pos++; + } else if (c === "." && !hasDecimal && !hasExponent) { + // Allow decimal point + numStr += c; + hasDecimal = true; + this.pos++; + } else if ((c === "e" || c === "E") && !hasExponent && numStr.length > 0) { + // Scientific notation + numStr += c; + hasExponent = true; + this.pos++; + + // Handle optional sign after exponent + if (this.pos < this.path.length) { + const signChar = this.path[this.pos]; + + if (signChar === "+" || signChar === "-") { + numStr += signChar; + this.pos++; + } + } + } else { + break; + } + } + + if (numStr === "" || numStr === "+" || numStr === "-" || numStr === ".") { + return null; + } + + const value = Number.parseFloat(numStr); + + return Number.isNaN(value) ? null : value; + } + + /** + * Read a command letter from the current position. + */ + readCommand(): SvgPathCommandType | null { + this.skipWhitespaceAndCommas(); + + if (this.pos >= this.path.length) { + return null; + } + + const char = this.path[this.pos]; + + if (isCommandLetter(char)) { + this.pos++; + + return char; + } + + return null; + } + + /** + * Peek at the next character without consuming it. + */ + peek(): string | null { + this.skipWhitespaceAndCommas(); + + if (this.pos >= this.path.length) { + return null; + } + + return this.path[this.pos]; + } + + /** + * Check if there's more content to parse. + */ + hasMore(): boolean { + this.skipWhitespaceAndCommas(); + + return this.pos < this.path.length; + } + + /** + * Read a single flag digit (0 or 1) for arc commands. + * SVG arc flags are special - they're single digits that can be + * concatenated without separators: "00" means two flags, both 0. + */ + readFlag(): number | null { + this.skipWhitespaceAndCommas(); + + if (this.pos >= this.path.length) { + return null; + } + + const char = this.path[this.pos]; + + if (char === "0" || char === "1") { + this.pos++; + return char === "1" ? 1 : 0; + } + + return null; + } +} + +/** + * Parse an SVG path string into an array of commands. + * + * @param pathData - The SVG path `d` attribute string + * @returns Array of parsed path commands + * + * @example + * ```typescript + * const commands = parseSvgPath("M 10 10 L 100 10 L 100 100 Z"); + * // [ + * // { type: "M", x: 10, y: 10 }, + * // { type: "L", x: 100, y: 10 }, + * // { type: "L", x: 100, y: 100 }, + * // { type: "Z" }, + * // ] + * ``` + */ +export function parseSvgPath(pathData: string): SvgPathCommand[] { + const commands: SvgPathCommand[] = []; + const tokenizer = new PathTokenizer(pathData); + + let currentCommand: SvgPathCommandType | null = null; + let isFirstInSequence = true; + + while (tokenizer.hasMore()) { + // Try to read a command letter + const nextChar = tokenizer.peek(); + + if (nextChar && isCommandLetter(nextChar)) { + currentCommand = tokenizer.readCommand(); + isFirstInSequence = true; + } + + if (!currentCommand) { + // Skip invalid character + break; + } + + // Get the number of parameters for this command + const paramCount = COMMAND_PARAMS[currentCommand]; + + // Handle close path (no parameters) + if (currentCommand === "Z" || currentCommand === "z") { + commands.push({ type: currentCommand }); + currentCommand = null; + + continue; + } + + // Read the required number of parameters + const params: number[] = []; + + // Arc commands need special handling for the flag parameters + const isArc = currentCommand === "A" || currentCommand === "a"; + + for (let i = 0; i < paramCount; i++) { + let num: number | null; + + // For arc commands, parameters 3 and 4 are single-digit flags (0 or 1) + // They can be packed together without separators: "00" means flag=0, flag=0 + if (isArc && (i === 3 || i === 4)) { + num = tokenizer.readFlag(); + } else { + num = tokenizer.readNumber(); + } + + if (num === null) { + // Not enough parameters - stop parsing this command + break; + } + + params.push(num); + } + + if (params.length !== paramCount) { + // Not enough parameters for this command - skip it + continue; + } + + // Create the command based on type + const command = createCommand(currentCommand, params); + + if (command) { + commands.push(command); + } + + // Handle implicit command repetition + // After M, implicit commands become L; after m, they become l + if (isFirstInSequence) { + isFirstInSequence = false; + + if (currentCommand === "M") { + currentCommand = "L"; + } else if (currentCommand === "m") { + currentCommand = "l"; + } + } + + // Check if there are more numbers (implicit repetition) + const nextPeek = tokenizer.peek(); + + if (!nextPeek || isCommandLetter(nextPeek)) { + // No more numbers for this command, reset for next command + if (!nextPeek) { + break; + } + } + } + + return commands; +} + +/** + * Create a command object from type and parameters. + */ +function createCommand(type: SvgPathCommandType, params: number[]): SvgPathCommand | null { + switch (type) { + case "M": + case "m": + return { type, x: params[0], y: params[1] }; + + case "L": + case "l": + return { type, x: params[0], y: params[1] }; + + case "H": + case "h": + return { type, x: params[0] }; + + case "V": + case "v": + return { type, y: params[0] }; + + case "C": + case "c": + return { + type, + x1: params[0], + y1: params[1], + x2: params[2], + y2: params[3], + x: params[4], + y: params[5], + }; + + case "S": + case "s": + return { type, x2: params[0], y2: params[1], x: params[2], y: params[3] }; + + case "Q": + case "q": + return { type, x1: params[0], y1: params[1], x: params[2], y: params[3] }; + + case "T": + case "t": + return { type, x: params[0], y: params[1] }; + + case "A": + case "a": + return { + type, + rx: params[0], + ry: params[1], + xAxisRotation: params[2], + largeArcFlag: params[3] !== 0, + sweepFlag: params[4] !== 0, + x: params[5], + y: params[6], + }; + + case "Z": + case "z": + return { type }; + + default: + return null; + } +} From 9ed17ae38c8c4fbad5f97e762dffa4270cb239d0 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Sun, 25 Jan 2026 23:07:44 +1100 Subject: [PATCH 2/5] chore: formatting --- content/docs/guides/drawing.mdx | 57 ++++++++++++++++----------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/content/docs/guides/drawing.mdx b/content/docs/guides/drawing.mdx index 0a4fff5..7a2621c 100644 --- a/content/docs/guides/drawing.mdx +++ b/content/docs/guides/drawing.mdx @@ -357,18 +357,18 @@ page.drawSvgPath(heartIcon, { x: 180, y: 700, scale: 3, color: rgb(1, 0, 0) }); All SVG path commands are supported: -| Command | Description | Example | -| ------- | ------------------- | --------------------- | -| M/m | Move to | `M 10 20` | -| L/l | Line to | `L 100 200` | -| H/h | Horizontal line | `H 150` | -| V/v | Vertical line | `V 100` | -| C/c | Cubic bezier | `C 10 20 30 40 50 60` | -| S/s | Smooth cubic | `S 30 40 50 60` | -| Q/q | Quadratic bezier | `Q 50 100 100 0` | -| T/t | Smooth quadratic | `T 150 0` | -| A/a | Elliptical arc | `A 50 50 0 0 1 100 0` | -| Z/z | Close path | `Z` | +| Command | Description | Example | +| ------- | ---------------- | --------------------- | +| M/m | Move to | `M 10 20` | +| L/l | Line to | `L 100 200` | +| H/h | Horizontal line | `H 150` | +| V/v | Vertical line | `V 100` | +| C/c | Cubic bezier | `C 10 20 30 40 50 60` | +| S/s | Smooth cubic | `S 30 40 50 60` | +| Q/q | Quadratic bezier | `Q 50 100 100 0` | +| T/t | Smooth quadratic | `T 150 0` | +| A/a | Elliptical arc | `A 50 50 0 0 1 100 0` | +| Z/z | Close path | `Z` | Uppercase commands use absolute coordinates; lowercase use relative coordinates. @@ -378,15 +378,12 @@ For paths with holes (like donuts or frames), use the even-odd winding rule: ```ts // Outer square with inner square hole -page.drawSvgPath( - "M 0 0 L 80 0 L 80 80 L 0 80 Z M 20 20 L 60 20 L 60 60 L 20 60 Z", - { - x: 50, - y: 600, - color: rgb(0, 0, 1), - windingRule: "evenodd", - } -); +page.drawSvgPath("M 0 0 L 80 0 L 80 80 L 0 80 Z M 20 20 L 60 20 L 60 60 L 20 60 Z", { + x: 50, + y: 600, + color: rgb(0, 0, 1), + windingRule: "evenodd", +}); ``` ### Using with PathBuilder @@ -435,16 +432,16 @@ page.drawSvgPath(githubPath, { ### Options Reference -| Option | Type | Description | -| ------------- | -------------- | ---------------------------------------- | -| `x` | `number` | X position on page | -| `y` | `number` | Y position on page | -| `scale` | `number` | Scale factor (default: 1) | -| `color` | `Color` | Fill color | -| `borderColor` | `Color` | Stroke color | -| `borderWidth` | `number` | Stroke width in points | +| Option | Type | Description | +| ------------- | ------------------------ | ------------------------------- | +| `x` | `number` | X position on page | +| `y` | `number` | Y position on page | +| `scale` | `number` | Scale factor (default: 1) | +| `color` | `Color` | Fill color | +| `borderColor` | `Color` | Stroke color | +| `borderWidth` | `number` | Stroke width in points | | `windingRule` | `"nonzero" \| "evenodd"` | Fill rule for overlapping paths | -| `opacity` | `number` | Opacity (0-1) | +| `opacity` | `number` | Opacity (0-1) | ## Drawing Order From 5847ae84d33f73e7d5597a5bbee2c0971a33656d Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Sun, 25 Jan 2026 23:14:09 +1100 Subject: [PATCH 3/5] docs(api): add drawSvgPath and appendSvgPath to PDFPage reference --- content/docs/api/pdf-page.mdx | 99 +++++++++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 10 deletions(-) diff --git a/content/docs/api/pdf-page.mdx b/content/docs/api/pdf-page.mdx index 7176fa7..feef537 100644 --- a/content/docs/api/pdf-page.mdx +++ b/content/docs/api/pdf-page.mdx @@ -358,6 +358,58 @@ page.drawEllipse({ --- +### drawSvgPath(pathData, options?) + +Draw an SVG path on the page. Useful for icons, logos, and vector graphics. + +| Param | Type | Default | Description | +| ------------------------- | -------------------------- | ----------- | ------------------------------------ | +| `pathData` | `string` | required | SVG path `d` attribute string | +| `[options]` | `DrawSvgPathOptions` | | | +| `[options.x]` | `number` | `0` | X position | +| `[options.y]` | `number` | `0` | Y position | +| `[options.scale]` | `number` | `1` | Scale factor | +| `[options.flipY]` | `boolean` | `true` | Flip Y-axis (SVG to PDF coordinates) | +| `[options.color]` | `Color` | black | Fill color | +| `[options.borderColor]` | `Color` | | Stroke color | +| `[options.borderWidth]` | `number` | `1` | Stroke width | +| `[options.opacity]` | `number` | `1` | Fill opacity | +| `[options.borderOpacity]` | `number` | `1` | Stroke opacity | +| `[options.windingRule]` | `"nonzero" \| "evenodd"` | `"nonzero"` | Fill rule for overlapping paths | +| `[options.lineCap]` | `LineCap` | | Line cap style | +| `[options.lineJoin]` | `LineJoin` | | Line join style | +| `[options.dashArray]` | `number[]` | | Dash pattern | + +All SVG path commands are supported: M, L, H, V, C, S, Q, T, A, Z (and lowercase relative variants). + +```typescript +// Simple filled triangle +page.drawSvgPath("M 0 0 L 50 0 L 25 40 Z", { + x: 100, + y: 700, + color: rgb(1, 0, 0), +}); + +// Stroked icon (Lucide-style) +page.drawSvgPath("M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3...", { + x: 50, + y: 600, + scale: 2, + borderColor: rgb(0.9, 0.2, 0.2), + borderWidth: 2, +}); + +// Path with hole (even-odd fill) +page.drawSvgPath( + "M 0 0 L 80 0 L 80 80 L 0 80 Z M 20 20 L 60 20 L 60 60 L 20 60 Z", + { x: 200, y: 500, color: rgb(0, 0, 1), windingRule: "evenodd" } +); +``` + +**Coordinate System**: By default, `flipY: true` transforms SVG coordinates (Y increases downward) to PDF coordinates (Y increases upward). Set `flipY: false` when paths are already in PDF coordinate space. + +--- + ## Image Drawing ### drawImage(image, options?) @@ -468,16 +520,43 @@ page **PathBuilder Methods**: -| Method | Description | -| --------------------------------------- | ------------------ | -| `moveTo(x, y)` | Move to point | -| `lineTo(x, y)` | Draw line to point | -| `curveTo(cp1x, cp1y, cp2x, cp2y, x, y)` | Bezier curve | -| `quadraticCurveTo(cpx, cpy, x, y)` | Quadratic curve | -| `close()` | Close the path | -| `fill(options)` | Fill the path | -| `stroke(options)` | Stroke the path | -| `fillAndStroke(options)` | Fill and stroke | +| Method | Description | +| --------------------------------------- | ------------------------ | +| `moveTo(x, y)` | Move to point | +| `lineTo(x, y)` | Draw line to point | +| `curveTo(cp1x, cp1y, cp2x, cp2y, x, y)` | Cubic bezier curve | +| `quadraticCurveTo(cpx, cpy, x, y)` | Quadratic bezier curve | +| `appendSvgPath(pathData, options?)` | Append SVG path commands | +| `close()` | Close the path | +| `fill(options)` | Fill the path | +| `stroke(options)` | Stroke the path | +| `fillAndStroke(options)` | Fill and stroke | + +**Using appendSvgPath**: + +The `appendSvgPath` method lets you mix SVG path commands with PathBuilder methods: + +```typescript +// Mix PathBuilder methods with SVG path data +page + .drawPath() + .moveTo(50, 500) + .lineTo(100, 500) + .appendSvgPath("l 30 -30 l 30 30", { flipY: false }) // relative SVG + .lineTo(200, 500) + .stroke({ borderColor: rgb(0, 0, 0) }); +``` + +Options for `appendSvgPath`: + +| Option | Type | Default | Description | +| ------------ | --------- | ------- | ------------------------------ | +| `flipY` | `boolean` | `false` | Flip Y-axis for SVG paths | +| `scale` | `number` | `1` | Scale factor | +| `translateX` | `number` | `0` | X translation after transform | +| `translateY` | `number` | `0` | Y translation after transform | + +Note: Unlike `drawSvgPath()`, the `appendSvgPath()` method defaults to `flipY: false` since PathBuilder operates in PDF coordinates. --- From 7a25c89d0b82f561dc283212761f7d8a106c102d Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 26 Jan 2026 11:06:27 +1100 Subject: [PATCH 4/5] refactor(svg): use object parameters for executor functions Improves readability for functions with many parameters by using named object properties instead of positional arguments. --- src/api/drawing/path-builder.ts | 12 +- src/svg/path-executor.test.ts | 80 +++---- src/svg/path-executor.ts | 362 +++++++++++++++++++------------- 3 files changed, 266 insertions(+), 188 deletions(-) diff --git a/src/api/drawing/path-builder.ts b/src/api/drawing/path-builder.ts index c0d2369..84e5137 100644 --- a/src/api/drawing/path-builder.ts +++ b/src/api/drawing/path-builder.ts @@ -241,9 +241,9 @@ export class PathBuilder { translateY: options.translateY, }; - executeSvgPathString( + executeSvgPathString({ pathData, - { + sink: { moveTo: (x: number, y: number) => { this.moveTo(x, y); }, @@ -260,10 +260,10 @@ export class PathBuilder { this.close(); }, }, - this.currentX, - this.currentY, - executorOptions, - ); + initialX: this.currentX, + initialY: this.currentY, + ...executorOptions, + }); return this; } diff --git a/src/svg/path-executor.test.ts b/src/svg/path-executor.test.ts index 4ad23d4..b73d32e 100644 --- a/src/svg/path-executor.test.ts +++ b/src/svg/path-executor.test.ts @@ -22,7 +22,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 10 20"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.moveTo).toHaveBeenCalledWith(10, 20); }); @@ -31,7 +31,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 0 0 L 100 200"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.lineTo).toHaveBeenCalledWith(100, 200); }); @@ -40,7 +40,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 10 20 H 100"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.lineTo).toHaveBeenCalledWith(100, 20); }); @@ -49,7 +49,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 10 20 V 100"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.lineTo).toHaveBeenCalledWith(10, 100); }); @@ -58,7 +58,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 0 0 C 10 20 30 40 50 60"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.curveTo).toHaveBeenCalledWith(10, 20, 30, 40, 50, 60); }); @@ -67,7 +67,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 0 0 Q 50 100 100 0"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.quadraticCurveTo).toHaveBeenCalledWith(50, 100, 100, 0); }); @@ -76,7 +76,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 0 0 L 100 0 L 50 100 Z"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.close).toHaveBeenCalled(); }); @@ -88,7 +88,7 @@ describe("executeSvgPath", () => { const commands = parseSvgPath("m 10 20"); // When starting from (0, 0), relative is same as absolute - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.moveTo).toHaveBeenCalledWith(10, 20); }); @@ -97,7 +97,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("m 10 10 l 5 0"); - executeSvgPath(commands, sink, 0, 0, { flipY: false, translateX: 100, translateY: 200 }); + executeSvgPath({ commands, sink, flipY: false, translateX: 100, translateY: 200 }); expect(sink.moveTo).toHaveBeenCalledWith(110, 210); expect(sink.lineTo).toHaveBeenCalledWith(115, 210); @@ -107,7 +107,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 100 100 l 50 50"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.lineTo).toHaveBeenCalledWith(150, 150); }); @@ -116,7 +116,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 100 50 h 25"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.lineTo).toHaveBeenCalledWith(125, 50); }); @@ -125,7 +125,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 50 100 v 25"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.lineTo).toHaveBeenCalledWith(50, 125); }); @@ -134,7 +134,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 100 100 c 10 20 30 40 50 60"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.curveTo).toHaveBeenCalledWith(110, 120, 130, 140, 150, 160); }); @@ -143,7 +143,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 100 100 q 25 50 50 0"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.quadraticCurveTo).toHaveBeenCalledWith(125, 150, 150, 100); }); @@ -152,7 +152,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 0 0 l 10 10 l 10 10 l 10 10"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.lineTo).toHaveBeenNthCalledWith(1, 10, 10); expect(sink.lineTo).toHaveBeenNthCalledWith(2, 20, 20); @@ -167,7 +167,7 @@ describe("executeSvgPath", () => { // Smooth curve should reflect CP2 around endpoint: 2*100-80=120, 2*100-80=120 const commands = parseSvgPath("M 0 0 C 20 20 80 80 100 100 S 180 180 200 200"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); // First curve expect(sink.curveTo).toHaveBeenNthCalledWith(1, 20, 20, 80, 80, 100, 100); @@ -180,7 +180,7 @@ describe("executeSvgPath", () => { // When S is not after C/c/S/s, first control point equals current point const commands = parseSvgPath("M 100 100 S 150 150 200 200"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); // CP1 should equal current point (100, 100) expect(sink.curveTo).toHaveBeenCalledWith(100, 100, 150, 150, 200, 200); @@ -192,7 +192,7 @@ describe("executeSvgPath", () => { // Smooth should reflect: 2*100-50=150, 2*0-100=-100 const commands = parseSvgPath("M 0 0 Q 50 100 100 0 T 200 0"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.quadraticCurveTo).toHaveBeenNthCalledWith(1, 50, 100, 100, 0); expect(sink.quadraticCurveTo).toHaveBeenNthCalledWith(2, 150, -100, 200, 0); @@ -202,7 +202,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 100 100 T 200 200"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); // CP should equal current point (100, 100), making it effectively a line expect(sink.quadraticCurveTo).toHaveBeenCalledWith(100, 100, 200, 200); @@ -212,7 +212,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 0 0 C 0 50 50 100 100 100 S 200 100 200 50 S 150 0 100 0"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.curveTo).toHaveBeenCalledTimes(3); }); @@ -221,7 +221,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 0 0 C 20 20 80 80 100 100 s 80 80 100 100"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); // Reflected CP1: 2*100-80=120, 2*100-80=120 // Relative CP2: 100+80=180, 100+80=180 @@ -235,7 +235,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 100 50 A 50 50 0 0 1 50 100"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); // Arc should be converted to at least one bezier curve expect(sink.curveTo).toHaveBeenCalled(); @@ -245,7 +245,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 100 50 a 50 50 0 0 1 -50 50"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); // Should convert arc ending at (50, 100) relative to start expect(sink.curveTo).toHaveBeenCalled(); @@ -260,7 +260,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 0 0 A 0 0 0 0 1 50 50"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); // Zero radius arc becomes a line expect(sink.curveTo).toHaveBeenCalled(); @@ -275,7 +275,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 100 100 L 200 100 L 200 200 Z L 300 300"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); // After Z, position returns to (100, 100) // Then L 300 300 draws from (100, 100) to (300, 300) @@ -286,7 +286,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 0 0 L 100 0 Z M 200 200 L 300 200 Z"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.moveTo).toHaveBeenCalledTimes(2); expect(sink.close).toHaveBeenCalledTimes(2); @@ -298,7 +298,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("l 50 50"); - executeSvgPath(commands, sink, 0, 0, noFlip); + executeSvgPath({ commands, sink, ...noFlip }); expect(sink.lineTo).toHaveBeenCalledWith(50, 50); }); @@ -307,7 +307,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("l 50 50"); - executeSvgPath(commands, sink, 100, 100, noFlip); + executeSvgPath({ commands, sink, initialX: 100, initialY: 100, ...noFlip }); expect(sink.lineTo).toHaveBeenCalledWith(150, 150); }); @@ -316,7 +316,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("m 10 20"); - executeSvgPath(commands, sink, 100, 100, noFlip); + executeSvgPath({ commands, sink, initialX: 100, initialY: 100, ...noFlip }); expect(sink.moveTo).toHaveBeenCalledWith(110, 120); }); @@ -327,7 +327,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 10 20 L 100 200"); - const result = executeSvgPath(commands, sink, 0, 0, noFlip); + const result = executeSvgPath({ commands, sink, ...noFlip }); expect(result).toEqual({ x: 100, y: 200 }); }); @@ -336,14 +336,14 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 50 50 L 100 100 Z"); - const result = executeSvgPath(commands, sink, 0, 0, noFlip); + const result = executeSvgPath({ commands, sink, ...noFlip }); expect(result).toEqual({ x: 50, y: 50 }); }); it("returns initial position for empty path", () => { const sink = createMockSink(); - const result = executeSvgPath([], sink, 25, 75, noFlip); + const result = executeSvgPath({ commands: [], sink, initialX: 25, initialY: 75, ...noFlip }); expect(result).toEqual({ x: 25, y: 75 }); }); @@ -353,7 +353,7 @@ describe("executeSvgPath", () => { it("parses and executes path string", () => { const sink = createMockSink(); - executeSvgPathString("M 10 20 L 100 200", sink, 0, 0, noFlip); + executeSvgPathString({ pathData: "M 10 20 L 100 200", sink, ...noFlip }); expect(sink.moveTo).toHaveBeenCalledWith(10, 20); expect(sink.lineTo).toHaveBeenCalledWith(100, 200); @@ -362,7 +362,7 @@ describe("executeSvgPath", () => { it("respects initial position", () => { const sink = createMockSink(); - executeSvgPathString("l 50 50", sink, 100, 100, noFlip); + executeSvgPathString({ pathData: "l 50 50", sink, initialX: 100, initialY: 100, ...noFlip }); expect(sink.lineTo).toHaveBeenCalledWith(150, 150); }); @@ -373,7 +373,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const path = "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"; - executeSvgPathString(path, sink, 0, 0, noFlip); + executeSvgPathString({ pathData: path, sink, ...noFlip }); expect(sink.moveTo).toHaveBeenCalledWith(10, 30); expect(sink.curveTo).toHaveBeenCalled(); // Arcs converted to beziers @@ -385,7 +385,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const path = "M 0 0 L 100 0 l 0 100 L 0 100 l 0 -100 Z"; - executeSvgPathString(path, sink, 0, 0, noFlip); + executeSvgPathString({ pathData: path, sink, ...noFlip }); expect(sink.moveTo).toHaveBeenCalledWith(0, 0); expect(sink.lineTo).toHaveBeenNthCalledWith(1, 100, 0); @@ -402,7 +402,7 @@ describe("executeSvgPath", () => { const commands = parseSvgPath("M 10 20 L 100 200"); // Default is flipY: true - executeSvgPath(commands, sink); + executeSvgPath({ commands, sink }); expect(sink.moveTo).toHaveBeenCalledWith(10, -20); expect(sink.lineTo).toHaveBeenCalledWith(100, -200); @@ -412,7 +412,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 10 20 L 100 200"); - executeSvgPath(commands, sink, 0, 0, { flipY: false }); + executeSvgPath({ commands, sink, flipY: false }); expect(sink.moveTo).toHaveBeenCalledWith(10, 20); expect(sink.lineTo).toHaveBeenCalledWith(100, 200); @@ -422,7 +422,7 @@ describe("executeSvgPath", () => { const sink = createMockSink(); const commands = parseSvgPath("M 0 0 l 50 50"); - executeSvgPath(commands, sink); + executeSvgPath({ commands, sink }); // With Y flip: 0 + (-50) = -50 expect(sink.lineTo).toHaveBeenCalledWith(50, -50); @@ -433,7 +433,7 @@ describe("executeSvgPath", () => { // A simple arc - sweep flag should be inverted when Y is flipped const commands = parseSvgPath("M 0 0 A 50 50 0 0 1 100 0"); - executeSvgPath(commands, sink); + executeSvgPath({ commands, sink }); // Arc should complete (be converted to beziers) expect(sink.curveTo).toHaveBeenCalled(); diff --git a/src/svg/path-executor.ts b/src/svg/path-executor.ts index 9adac61..59e0f1f 100644 --- a/src/svg/path-executor.ts +++ b/src/svg/path-executor.ts @@ -115,25 +115,37 @@ interface ExecutorState { * normalization, so the sink receives only absolute coordinates * and standard path operations. * - * @param commands - Parsed SVG path commands - * @param sink - Callback interface for path operations - * @param initialX - Initial X coordinate (default: 0) - * @param initialY - Initial Y coordinate (default: 0) - * @param options - Execution options (flipY, etc.) + * @param options - Execution options + * @param options.commands - Parsed SVG path commands + * @param options.sink - Callback interface for path operations + * @param options.initialX - Initial X coordinate (default: 0) + * @param options.initialY - Initial Y coordinate (default: 0) + * @param options.flipY - Flip Y coordinates for PDF (default: true) + * @param options.scale - Scale factor (default: 1) + * @param options.translateX - X offset after transform (default: 0) + * @param options.translateY - Y offset after transform (default: 0) * @returns Final position {x, y} after executing all commands */ export function executeSvgPath( - commands: SvgPathCommand[], - sink: PathSink, - initialX = 0, - initialY = 0, - options: SvgPathExecutorOptions = {}, + options: { + commands: SvgPathCommand[]; + sink: PathSink; + initialX?: number; + initialY?: number; + } & SvgPathExecutorOptions, ): { x: number; y: number } { - const flipY = options.flipY ?? true; + const { + commands, + sink, + initialX = 0, + initialY = 0, + flipY = true, + scale = 1, + translateX = 0, + translateY = 0, + } = options; + const yFlip = flipY ? -1 : 1; - const scale = options.scale ?? 1; - const translateX = options.translateX ?? 0; - const translateY = options.translateY ?? 0; const initialOutputX = initialX + translateX; const initialOutputY = initialY + translateY; @@ -177,110 +189,152 @@ function transformY(y: number, state: ExecutorState): number { function executeCommand(cmd: SvgPathCommand, state: ExecutorState, sink: PathSink): void { switch (cmd.type) { case "M": - executeMoveTo(cmd.x, cmd.y, false, state, sink); + executeMoveTo({ x: cmd.x, y: cmd.y, relative: false, state, sink }); break; case "m": - executeMoveTo(cmd.x, cmd.y, true, state, sink); + executeMoveTo({ x: cmd.x, y: cmd.y, relative: true, state, sink }); break; case "L": - executeLineTo(cmd.x, cmd.y, false, state, sink); + executeLineTo({ x: cmd.x, y: cmd.y, relative: false, state, sink }); break; case "l": - executeLineTo(cmd.x, cmd.y, true, state, sink); + executeLineTo({ x: cmd.x, y: cmd.y, relative: true, state, sink }); break; case "H": - // Absolute horizontal: transform X, keep current Y (already in output space) - executeHorizontalLine(cmd.x, false, state, sink); + executeHorizontalLine({ x: cmd.x, relative: false, state, sink }); break; case "h": - // Relative horizontal: add scaled delta to current X - executeHorizontalLine(cmd.x, true, state, sink); + executeHorizontalLine({ x: cmd.x, relative: true, state, sink }); break; case "V": - // Absolute vertical: keep current X, transform Y - executeVerticalLine(cmd.y, false, state, sink); + executeVerticalLine({ y: cmd.y, relative: false, state, sink }); break; case "v": - // Relative vertical: add scaled & flipped delta to current Y - executeVerticalLine(cmd.y, true, state, sink); + executeVerticalLine({ y: cmd.y, relative: true, state, sink }); break; case "C": - executeCubicCurve(cmd.x1, cmd.y1, cmd.x2, cmd.y2, cmd.x, cmd.y, false, state, sink); + executeCubicCurve({ + x1: cmd.x1, + y1: cmd.y1, + x2: cmd.x2, + y2: cmd.y2, + x: cmd.x, + y: cmd.y, + relative: false, + state, + sink, + }); break; case "c": - executeCubicCurve(cmd.x1, cmd.y1, cmd.x2, cmd.y2, cmd.x, cmd.y, true, state, sink); + executeCubicCurve({ + x1: cmd.x1, + y1: cmd.y1, + x2: cmd.x2, + y2: cmd.y2, + x: cmd.x, + y: cmd.y, + relative: true, + state, + sink, + }); break; case "S": - executeSmoothCubic(cmd.x2, cmd.y2, cmd.x, cmd.y, false, state, sink); + executeSmoothCubic({ + x2: cmd.x2, + y2: cmd.y2, + x: cmd.x, + y: cmd.y, + relative: false, + state, + sink, + }); break; case "s": - executeSmoothCubic(cmd.x2, cmd.y2, cmd.x, cmd.y, true, state, sink); + executeSmoothCubic({ + x2: cmd.x2, + y2: cmd.y2, + x: cmd.x, + y: cmd.y, + relative: true, + state, + sink, + }); break; case "Q": - executeQuadratic(cmd.x1, cmd.y1, cmd.x, cmd.y, false, state, sink); + executeQuadratic({ + x1: cmd.x1, + y1: cmd.y1, + x: cmd.x, + y: cmd.y, + relative: false, + state, + sink, + }); break; case "q": - executeQuadratic(cmd.x1, cmd.y1, cmd.x, cmd.y, true, state, sink); + executeQuadratic({ x1: cmd.x1, y1: cmd.y1, x: cmd.x, y: cmd.y, relative: true, state, sink }); break; case "T": - executeSmoothQuadratic(cmd.x, cmd.y, false, state, sink); + executeSmoothQuadratic({ x: cmd.x, y: cmd.y, relative: false, state, sink }); break; case "t": - executeSmoothQuadratic(cmd.x, cmd.y, true, state, sink); + executeSmoothQuadratic({ x: cmd.x, y: cmd.y, relative: true, state, sink }); break; case "A": - executeArc( - cmd.rx, - cmd.ry, - cmd.xAxisRotation, - cmd.largeArcFlag, - cmd.sweepFlag, - cmd.x, - cmd.y, - false, + executeArc({ + rx: cmd.rx, + ry: cmd.ry, + xAxisRotation: cmd.xAxisRotation, + largeArcFlag: cmd.largeArcFlag, + sweepFlag: cmd.sweepFlag, + x: cmd.x, + y: cmd.y, + relative: false, state, sink, - ); + }); break; case "a": - executeArc( - cmd.rx, - cmd.ry, - cmd.xAxisRotation, - cmd.largeArcFlag, - cmd.sweepFlag, - cmd.x, - cmd.y, - true, + executeArc({ + rx: cmd.rx, + ry: cmd.ry, + xAxisRotation: cmd.xAxisRotation, + largeArcFlag: cmd.largeArcFlag, + sweepFlag: cmd.sweepFlag, + x: cmd.x, + y: cmd.y, + relative: true, state, sink, - ); + }); break; case "Z": case "z": - executeClose(state, sink); + executeClose({ state, sink }); break; } state.lastCommand = cmd.type; } -function executeMoveTo( - x: number, - y: number, - relative: boolean, - state: ExecutorState, - sink: PathSink, -): void { +function executeMoveTo(options: { + x: number; + y: number; + relative: boolean; + state: ExecutorState; + sink: PathSink; +}): void { + const { x, y, relative, state, sink } = options; + // For relative: add delta to current position (in SVG space, before transform) // For absolute: use the coordinate directly (in SVG space) // We track position in OUTPUT space, so we need to work backwards for relative @@ -299,13 +353,15 @@ function executeMoveTo( state.lastControlY = outY; } -function executeLineTo( - x: number, - y: number, - relative: boolean, - state: ExecutorState, - sink: PathSink, -): void { +function executeLineTo(options: { + x: number; + y: number; + relative: boolean; + state: ExecutorState; + sink: PathSink; +}): void { + const { x, y, relative, state, sink } = options; + const outX = relative ? state.currentX + x * state.scale : transformX(x, state); const outY = relative ? state.currentY + y * state.yFlip * state.scale : transformY(y, state); @@ -317,12 +373,14 @@ function executeLineTo( state.lastControlY = outY; } -function executeHorizontalLine( - x: number, - relative: boolean, - state: ExecutorState, - sink: PathSink, -): void { +function executeHorizontalLine(options: { + x: number; + relative: boolean; + state: ExecutorState; + sink: PathSink; +}): void { + const { x, relative, state, sink } = options; + const outX = relative ? state.currentX + x * state.scale : transformX(x, state); // Y stays the same (already in output space) @@ -333,12 +391,14 @@ function executeHorizontalLine( state.lastControlY = state.currentY; } -function executeVerticalLine( - y: number, - relative: boolean, - state: ExecutorState, - sink: PathSink, -): void { +function executeVerticalLine(options: { + y: number; + relative: boolean; + state: ExecutorState; + sink: PathSink; +}): void { + const { y, relative, state, sink } = options; + const outY = relative ? state.currentY + y * state.yFlip * state.scale : transformY(y, state); // X stays the same (already in output space) @@ -349,17 +409,19 @@ function executeVerticalLine( state.lastControlY = outY; } -function executeCubicCurve( - x1: number, - y1: number, - x2: number, - y2: number, - x: number, - y: number, - relative: boolean, - state: ExecutorState, - sink: PathSink, -): void { +function executeCubicCurve(options: { + x1: number; + y1: number; + x2: number; + y2: number; + x: number; + y: number; + relative: boolean; + state: ExecutorState; + sink: PathSink; +}): void { + const { x1, y1, x2, y2, x, y, relative, state, sink } = options; + const outX1 = relative ? state.currentX + x1 * state.scale : transformX(x1, state); const outY1 = relative ? state.currentY + y1 * state.yFlip * state.scale : transformY(y1, state); const outX2 = relative ? state.currentX + x2 * state.scale : transformX(x2, state); @@ -376,15 +438,17 @@ function executeCubicCurve( state.lastControlY = outY2; } -function executeSmoothCubic( - x2: number, - y2: number, - x: number, - y: number, - relative: boolean, - state: ExecutorState, - sink: PathSink, -): void { +function executeSmoothCubic(options: { + x2: number; + y2: number; + x: number; + y: number; + relative: boolean; + state: ExecutorState; + sink: PathSink; +}): void { + const { x2, y2, x, y, relative, state, sink } = options; + // Reflect the last control point across current point to get first control point // (already in output space) let cp1x: number; @@ -418,15 +482,17 @@ function executeSmoothCubic( state.lastControlY = outY2; } -function executeQuadratic( - x1: number, - y1: number, - x: number, - y: number, - relative: boolean, - state: ExecutorState, - sink: PathSink, -): void { +function executeQuadratic(options: { + x1: number; + y1: number; + x: number; + y: number; + relative: boolean; + state: ExecutorState; + sink: PathSink; +}): void { + const { x1, y1, x, y, relative, state, sink } = options; + const outCpX = relative ? state.currentX + x1 * state.scale : transformX(x1, state); const outCpY = relative ? state.currentY + y1 * state.yFlip * state.scale : transformY(y1, state); const outX = relative ? state.currentX + x * state.scale : transformX(x, state); @@ -441,13 +507,15 @@ function executeQuadratic( state.lastControlY = outCpY; } -function executeSmoothQuadratic( - x: number, - y: number, - relative: boolean, - state: ExecutorState, - sink: PathSink, -): void { +function executeSmoothQuadratic(options: { + x: number; + y: number; + relative: boolean; + state: ExecutorState; + sink: PathSink; +}): void { + const { x, y, relative, state, sink } = options; + // Reflect the last control point across current point (already in output space) let cpx: number; let cpy: number; @@ -478,18 +546,20 @@ function executeSmoothQuadratic( state.lastControlY = cpy; } -function executeArc( - rx: number, - ry: number, - xAxisRotation: number, - largeArcFlag: boolean, - sweepFlag: boolean, - x: number, - y: number, - relative: boolean, - state: ExecutorState, - sink: PathSink, -): void { +function executeArc(options: { + rx: number; + ry: number; + xAxisRotation: number; + largeArcFlag: boolean; + sweepFlag: boolean; + x: number; + y: number; + relative: boolean; + state: ExecutorState; + sink: PathSink; +}): void { + const { rx, ry, xAxisRotation, largeArcFlag, sweepFlag, x, y, relative, state, sink } = options; + const outX = relative ? state.currentX + x * state.scale : transformX(x, state); const outY = relative ? state.currentY + y * state.yFlip * state.scale : transformY(y, state); @@ -524,7 +594,9 @@ function executeArc( state.lastControlY = outY; } -function executeClose(state: ExecutorState, sink: PathSink): void { +function executeClose(options: { state: ExecutorState; sink: PathSink }): void { + const { state, sink } = options; + sink.close(); // After close, current point returns to subpath start @@ -543,11 +615,15 @@ function executeClose(state: ExecutorState, sink: PathSink): void { * top-left origin to PDF's bottom-left origin. Set `flipY: false` in * options to disable this behavior. * - * @param pathData - SVG path d string - * @param sink - Callback interface for path operations - * @param initialX - Initial X coordinate (default: 0) - * @param initialY - Initial Y coordinate (default: 0) - * @param options - Execution options (flipY, etc.) + * @param options - Execution options + * @param options.pathData - SVG path d string + * @param options.sink - Callback interface for path operations + * @param options.initialX - Initial X coordinate (default: 0) + * @param options.initialY - Initial Y coordinate (default: 0) + * @param options.flipY - Flip Y coordinates for PDF (default: true) + * @param options.scale - Scale factor (default: 1) + * @param options.translateX - X offset after transform (default: 0) + * @param options.translateY - Y offset after transform (default: 0) * @returns Final position {x, y} after executing all commands * * @example @@ -560,17 +636,19 @@ function executeClose(state: ExecutorState, sink: PathSink): void { * close: () => console.log(`Z`), * }; * - * executeSvgPathString("M 10 10 L 100 10 L 100 100 Z", sink); + * executeSvgPathString({ pathData: "M 10 10 L 100 10 L 100 100 Z", sink }); * ``` */ export function executeSvgPathString( - pathData: string, - sink: PathSink, - initialX = 0, - initialY = 0, - options: SvgPathExecutorOptions = {}, + options: { + pathData: string; + sink: PathSink; + initialX?: number; + initialY?: number; + } & SvgPathExecutorOptions, ): { x: number; y: number } { + const { pathData, sink, initialX, initialY, ...executorOptions } = options; const commands = parseSvgPath(pathData); - return executeSvgPath(commands, sink, initialX, initialY, options); + return executeSvgPath({ commands, sink, initialX, initialY, ...executorOptions }); } From 50a03c01929c126b1401a3086187cf0a3859f5c9 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 26 Jan 2026 11:07:24 +1100 Subject: [PATCH 5/5] chore: formatting --- content/docs/api/pdf-page.mdx | 56 ++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/content/docs/api/pdf-page.mdx b/content/docs/api/pdf-page.mdx index feef537..e2b0308 100644 --- a/content/docs/api/pdf-page.mdx +++ b/content/docs/api/pdf-page.mdx @@ -362,23 +362,23 @@ page.drawEllipse({ Draw an SVG path on the page. Useful for icons, logos, and vector graphics. -| Param | Type | Default | Description | -| ------------------------- | -------------------------- | ----------- | ------------------------------------ | -| `pathData` | `string` | required | SVG path `d` attribute string | -| `[options]` | `DrawSvgPathOptions` | | | -| `[options.x]` | `number` | `0` | X position | -| `[options.y]` | `number` | `0` | Y position | -| `[options.scale]` | `number` | `1` | Scale factor | -| `[options.flipY]` | `boolean` | `true` | Flip Y-axis (SVG to PDF coordinates) | -| `[options.color]` | `Color` | black | Fill color | -| `[options.borderColor]` | `Color` | | Stroke color | -| `[options.borderWidth]` | `number` | `1` | Stroke width | -| `[options.opacity]` | `number` | `1` | Fill opacity | -| `[options.borderOpacity]` | `number` | `1` | Stroke opacity | -| `[options.windingRule]` | `"nonzero" \| "evenodd"` | `"nonzero"` | Fill rule for overlapping paths | -| `[options.lineCap]` | `LineCap` | | Line cap style | -| `[options.lineJoin]` | `LineJoin` | | Line join style | -| `[options.dashArray]` | `number[]` | | Dash pattern | +| Param | Type | Default | Description | +| ------------------------- | ------------------------ | ----------- | ------------------------------------ | +| `pathData` | `string` | required | SVG path `d` attribute string | +| `[options]` | `DrawSvgPathOptions` | | | +| `[options.x]` | `number` | `0` | X position | +| `[options.y]` | `number` | `0` | Y position | +| `[options.scale]` | `number` | `1` | Scale factor | +| `[options.flipY]` | `boolean` | `true` | Flip Y-axis (SVG to PDF coordinates) | +| `[options.color]` | `Color` | black | Fill color | +| `[options.borderColor]` | `Color` | | Stroke color | +| `[options.borderWidth]` | `number` | `1` | Stroke width | +| `[options.opacity]` | `number` | `1` | Fill opacity | +| `[options.borderOpacity]` | `number` | `1` | Stroke opacity | +| `[options.windingRule]` | `"nonzero" \| "evenodd"` | `"nonzero"` | Fill rule for overlapping paths | +| `[options.lineCap]` | `LineCap` | | Line cap style | +| `[options.lineJoin]` | `LineJoin` | | Line join style | +| `[options.dashArray]` | `number[]` | | Dash pattern | All SVG path commands are supported: M, L, H, V, C, S, Q, T, A, Z (and lowercase relative variants). @@ -400,10 +400,12 @@ page.drawSvgPath("M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3...", { }); // Path with hole (even-odd fill) -page.drawSvgPath( - "M 0 0 L 80 0 L 80 80 L 0 80 Z M 20 20 L 60 20 L 60 60 L 20 60 Z", - { x: 200, y: 500, color: rgb(0, 0, 1), windingRule: "evenodd" } -); +page.drawSvgPath("M 0 0 L 80 0 L 80 80 L 0 80 Z M 20 20 L 60 20 L 60 60 L 20 60 Z", { + x: 200, + y: 500, + color: rgb(0, 0, 1), + windingRule: "evenodd", +}); ``` **Coordinate System**: By default, `flipY: true` transforms SVG coordinates (Y increases downward) to PDF coordinates (Y increases upward). Set `flipY: false` when paths are already in PDF coordinate space. @@ -549,12 +551,12 @@ page Options for `appendSvgPath`: -| Option | Type | Default | Description | -| ------------ | --------- | ------- | ------------------------------ | -| `flipY` | `boolean` | `false` | Flip Y-axis for SVG paths | -| `scale` | `number` | `1` | Scale factor | -| `translateX` | `number` | `0` | X translation after transform | -| `translateY` | `number` | `0` | Y translation after transform | +| Option | Type | Default | Description | +| ------------ | --------- | ------- | ----------------------------- | +| `flipY` | `boolean` | `false` | Flip Y-axis for SVG paths | +| `scale` | `number` | `1` | Scale factor | +| `translateX` | `number` | `0` | X translation after transform | +| `translateY` | `number` | `0` | Y translation after transform | Note: Unlike `drawSvgPath()`, the `appendSvgPath()` method defaults to `flipY: false` since PathBuilder operates in PDF coordinates.