|
| 1 | +# Plan 045: Low-Level Drawing API |
| 2 | + |
| 3 | +## Problem |
| 4 | + |
| 5 | +We have robust drawing internals (operators, Matrix class, graphics state) but they're not exposed publicly. Users who need advanced control—matrix transforms, graphics state stack, gradients, patterns—can't access these primitives. This limits our ability to support: |
| 6 | + |
| 7 | +- Libraries that need low-level PDF generation (like svg2pdf) |
| 8 | +- Power users building custom rendering pipelines |
| 9 | +- Future packages like a Canvas2D-compatible layer (`@libpdf/canvas`) |
| 10 | + |
| 11 | +## Goals |
| 12 | + |
| 13 | +1. Expose PDF operators as a public API |
| 14 | +2. Add resource registration methods for use with raw operators |
| 15 | +3. Add shading (gradient) and pattern support |
| 16 | +4. Expose the Matrix class for transform composition |
| 17 | +5. Lay groundwork for a future `@libpdf/canvas` package |
| 18 | + |
| 19 | +## Non-Goals |
| 20 | + |
| 21 | +- Implementing the Canvas2D package itself (future work) |
| 22 | +- High-level gradient/pattern helpers on existing draw methods (can add later) |
| 23 | +- Backwards-incompatible changes to existing API |
| 24 | +- Validation of operator sequences (caller responsibility) |
| 25 | + |
| 26 | +## Design Decisions |
| 27 | + |
| 28 | +1. **No validation in `drawOperators`** - caller is responsible for valid operator sequences |
| 29 | +2. **No raw `appendContentStream`** - keep API typed via `drawOperators` |
| 30 | +3. **`registerXXX` naming** - consistent pattern for all resource registration methods |
| 31 | +4. **Operators accept names with or without slash** - `/F1` and `F1` both work, normalized internally |
| 32 | +5. **Text operators accept string | PdfString** - strings auto-encoded via `PdfString.fromString()` (picks optimal encoding) |
| 33 | +6. **Gradient coordinates in user space** - matches PDF spec; gradients stay fixed, shapes reveal them |
| 34 | +7. **Resource deduplication by instance** - same object registered twice returns same name |
| 35 | +8. **Pattern paint callbacks are synchronous** - resources must be pre-embedded |
| 36 | +9. **Batch operator emission only** - `drawOperators([...])`, no streaming append |
| 37 | +10. **PathBuilder remains separate** - different abstraction level from raw operators |
| 38 | + |
| 39 | +--- |
| 40 | + |
| 41 | +## Desired Usage |
| 42 | + |
| 43 | +### Raw Operators |
| 44 | + |
| 45 | +```typescript |
| 46 | +import { PDF, ops } from "@libpdf/core"; |
| 47 | + |
| 48 | +const pdf = await PDF.create(); |
| 49 | +const page = pdf.addPage(); |
| 50 | + |
| 51 | +page.drawOperators([ |
| 52 | + ops.pushGraphicsState(), |
| 53 | + ops.concatMatrix(1, 0, 0, 1, 100, 200), // translate |
| 54 | + ops.setNonStrokingRGB(1, 0, 0), |
| 55 | + ops.rectangle(0, 0, 50, 50), |
| 56 | + ops.fill(), |
| 57 | + ops.popGraphicsState(), |
| 58 | +]); |
| 59 | +``` |
| 60 | + |
| 61 | +### Resource Registration |
| 62 | + |
| 63 | +```typescript |
| 64 | +const font = await pdf.embedFont(fontBytes); |
| 65 | +const image = await pdf.embedImage(imageBytes); |
| 66 | + |
| 67 | +// Register resources on page, get operator names |
| 68 | +const fontName = page.registerFont(font); // "F0" |
| 69 | +const imageName = page.registerImage(image); // "Im0" |
| 70 | + |
| 71 | +// Names work with or without slash prefix |
| 72 | +page.drawOperators([ |
| 73 | + ops.beginText(), |
| 74 | + ops.setFont(fontName, 12), // "F0" works |
| 75 | + ops.setFont("/F0", 12), // "/F0" also works |
| 76 | + ops.moveText(100, 700), |
| 77 | + ops.showText("Hello"), // string auto-encoded |
| 78 | + ops.endText(), |
| 79 | + ops.pushGraphicsState(), |
| 80 | + ops.concatMatrix(100, 0, 0, 100, 200, 500), |
| 81 | + ops.paintXObject(imageName), |
| 82 | + ops.popGraphicsState(), |
| 83 | +]); |
| 84 | +``` |
| 85 | + |
| 86 | +### Matrix Transforms |
| 87 | + |
| 88 | +```typescript |
| 89 | +import { Matrix, ops } from "@libpdf/core"; |
| 90 | + |
| 91 | +// Compose transforms using Matrix class |
| 92 | +const transform = Matrix.identity().translate(100, 200).rotate(45).scale(2, 2); |
| 93 | + |
| 94 | +page.drawOperators([ |
| 95 | + ops.pushGraphicsState(), |
| 96 | + ops.concatMatrix(...transform.toArray()), // [a, b, c, d, e, f] |
| 97 | + // ... drawing operations |
| 98 | + ops.popGraphicsState(), |
| 99 | +]); |
| 100 | + |
| 101 | +// Or use concatMatrix directly with Matrix |
| 102 | +page.drawOperators([ |
| 103 | + ops.concatMatrix(transform), // accepts Matrix or 6 numbers |
| 104 | +]); |
| 105 | +``` |
| 106 | + |
| 107 | +### Gradients (Shading) |
| 108 | + |
| 109 | +```typescript |
| 110 | +// Low-level: explicit coordinates |
| 111 | +const gradient = pdf.createAxialShading({ |
| 112 | + coords: [0, 0, 100, 0], // x0, y0, x1, y1 |
| 113 | + stops: [ |
| 114 | + { offset: 0, color: rgb(1, 0, 0) }, |
| 115 | + { offset: 0.5, color: rgb(0, 1, 0) }, |
| 116 | + { offset: 1, color: rgb(0, 0, 1) }, |
| 117 | + ], |
| 118 | +}); |
| 119 | + |
| 120 | +// Convenience: angle-based (CSS convention) |
| 121 | +const horizontalGradient = pdf.createLinearGradient({ |
| 122 | + angle: 90, // CSS convention: 0 = up, 90 = right, 180 = down, 270 = left |
| 123 | + length: 100, |
| 124 | + stops: [ |
| 125 | + { offset: 0, color: rgb(1, 0, 0) }, |
| 126 | + { offset: 1, color: rgb(0, 0, 1) }, |
| 127 | + ], |
| 128 | +}); |
| 129 | + |
| 130 | +const shadingName = page.registerShading(gradient); |
| 131 | + |
| 132 | +page.drawOperators([ |
| 133 | + ops.pushGraphicsState(), |
| 134 | + ops.rectangle(50, 50, 100, 100), |
| 135 | + ops.clip(), |
| 136 | + ops.endPath(), |
| 137 | + ops.paintShading(shadingName), |
| 138 | + ops.popGraphicsState(), |
| 139 | +]); |
| 140 | +``` |
| 141 | + |
| 142 | +### Radial Gradients |
| 143 | + |
| 144 | +```typescript |
| 145 | +const radial = pdf.createRadialShading({ |
| 146 | + coords: [50, 50, 0, 50, 50, 50], // x0, y0, r0, x1, y1, r1 |
| 147 | + stops: [ |
| 148 | + { offset: 0, color: rgb(1, 1, 1) }, |
| 149 | + { offset: 1, color: rgb(0, 0, 0) }, |
| 150 | + ], |
| 151 | + extend: [true, true], // extend beyond start/end circles |
| 152 | +}); |
| 153 | +``` |
| 154 | + |
| 155 | +### Patterns |
| 156 | + |
| 157 | +```typescript |
| 158 | +const pattern = pdf.createTilingPattern({ |
| 159 | + bbox: [0, 0, 10, 10], |
| 160 | + xStep: 10, |
| 161 | + yStep: 10, |
| 162 | + paint: ctx => { |
| 163 | + ctx.drawOperators([ |
| 164 | + ops.setNonStrokingRGB(0.8, 0.8, 0.8), |
| 165 | + ops.rectangle(0, 0, 5, 5), |
| 166 | + ops.fill(), |
| 167 | + ]); |
| 168 | + }, |
| 169 | +}); |
| 170 | + |
| 171 | +const patternName = page.registerPattern(pattern); |
| 172 | + |
| 173 | +page.drawOperators([ |
| 174 | + ops.setNonStrokingColorSpace(ColorSpace.Pattern), |
| 175 | + ops.setNonStrokingColorN(patternName), |
| 176 | + ops.rectangle(100, 100, 200, 200), |
| 177 | + ops.fill(), |
| 178 | +]); |
| 179 | +``` |
| 180 | + |
| 181 | +### Extended Graphics State |
| 182 | + |
| 183 | +```typescript |
| 184 | +const gs = pdf.createExtGState({ |
| 185 | + fillOpacity: 0.5, |
| 186 | + strokeOpacity: 0.8, |
| 187 | + blendMode: "Multiply", |
| 188 | +}); |
| 189 | + |
| 190 | +const gsName = page.registerExtGState(gs); |
| 191 | + |
| 192 | +page.drawOperators([ |
| 193 | + ops.pushGraphicsState(), |
| 194 | + ops.setGraphicsState(gsName), |
| 195 | + ops.setNonStrokingRGB(1, 0, 0), |
| 196 | + ops.rectangle(100, 100, 50, 50), |
| 197 | + ops.fill(), |
| 198 | + ops.popGraphicsState(), |
| 199 | +]); |
| 200 | +``` |
| 201 | + |
| 202 | +### Form XObjects (Reusable Content) |
| 203 | + |
| 204 | +```typescript |
| 205 | +// Create a reusable content group |
| 206 | +const stamp = pdf.createFormXObject({ |
| 207 | + bbox: [0, 0, 100, 50], |
| 208 | + paint: ctx => { |
| 209 | + ctx.drawOperators([ |
| 210 | + ops.setNonStrokingRGB(1, 0, 0), |
| 211 | + ops.rectangle(0, 0, 100, 50), |
| 212 | + ops.fill(), |
| 213 | + ops.setNonStrokingRGB(1, 1, 1), |
| 214 | + ops.beginText(), |
| 215 | + ops.setFont("Helvetica", 12), |
| 216 | + ops.moveText(10, 20), |
| 217 | + ops.showText("DRAFT"), |
| 218 | + ops.endText(), |
| 219 | + ]); |
| 220 | + }, |
| 221 | +}); |
| 222 | + |
| 223 | +// Use on multiple pages |
| 224 | +for (const page of pdf.getPages()) { |
| 225 | + const name = page.registerXObject(stamp); |
| 226 | + page.drawOperators([ |
| 227 | + ops.pushGraphicsState(), |
| 228 | + ops.concatMatrix(1, 0, 0, 1, 200, 700), |
| 229 | + ops.paintXObject(name), |
| 230 | + ops.popGraphicsState(), |
| 231 | + ]); |
| 232 | +} |
| 233 | +``` |
| 234 | + |
| 235 | +--- |
| 236 | + |
| 237 | +## Architecture |
| 238 | + |
| 239 | +### Public Exports |
| 240 | + |
| 241 | +The library should export: |
| 242 | + |
| 243 | +- **`ops` namespace** — all PDF operators as factory functions |
| 244 | +- **`Matrix` class** — for composing transforms before passing to operators |
| 245 | +- **`ColorSpace` constants** — DeviceGray, DeviceRGB, DeviceCMYK, Pattern |
| 246 | +- **Types** — PDFShading, PDFPattern, PDFExtGState, PDFFormXObject, and their option types |
| 247 | + |
| 248 | +### PDFPage Additions |
| 249 | + |
| 250 | +New methods on PDFPage: |
| 251 | + |
| 252 | +- **`drawOperators(operators[])`** — emits raw operators to page content stream |
| 253 | +- **`registerFont(font)`** — registers font, returns operator name (e.g., "F0") |
| 254 | +- **`registerImage(image)`** — registers image, returns name |
| 255 | +- **`registerShading(shading)`** — registers shading, returns name |
| 256 | +- **`registerPattern(pattern)`** — registers pattern, returns name |
| 257 | +- **`registerExtGState(state)`** — registers graphics state, returns name |
| 258 | +- **`registerXObject(xobject)`** — registers form XObject, returns name |
| 259 | + |
| 260 | +### PDF Class Additions |
| 261 | + |
| 262 | +New factory methods on PDF. Each creates a PDF object, adds it to the document's object table, and returns a wrapper holding the ref: |
| 263 | + |
| 264 | +- **`createAxialShading(options)`** — creates linear gradient with explicit coordinates |
| 265 | +- **`createRadialShading(options)`** — creates radial gradient |
| 266 | +- **`createLinearGradient(options)`** — convenience method using angle instead of coordinates |
| 267 | +- **`createTilingPattern(options)`** — creates repeating pattern |
| 268 | +- **`createExtGState(options)`** — creates extended graphics state (opacity, blend mode) |
| 269 | +- **`createFormXObject(options)`** — creates reusable content group |
| 270 | + |
| 271 | +The returned wrapper types (PDFShading, PDFPattern, etc.) hold a ref internally. When passed to `page.registerX()`, the ref is added to the page's resource dictionary. |
| 272 | + |
| 273 | +### Operator Behavior |
| 274 | + |
| 275 | +- **Name normalization**: Operators that take resource names (setFont, setGraphicsState, paintXObject, etc.) should accept names with or without the leading slash — "F0" and "/F0" both work |
| 276 | +- **Text encoding**: showText should accept plain strings (auto-encoded via PdfString.fromString) or PdfString instances |
| 277 | +- **Matrix overload**: concatMatrix should accept either a Matrix instance or 6 individual numbers |
| 278 | + |
| 279 | +### Matrix Class |
| 280 | + |
| 281 | +The Matrix class should support: |
| 282 | + |
| 283 | +- Static factories: `identity()`, `translate()`, `scale()`, `rotate()` |
| 284 | +- Instance methods for chaining: `translate()`, `scale()`, `rotate()`, `multiply()` |
| 285 | +- Conversion: `toArray()` returning `[a, b, c, d, e, f]` for use with concatMatrix |
| 286 | + |
| 287 | +### Pattern and FormXObject Contexts |
| 288 | + |
| 289 | +When creating patterns or form XObjects, the paint callback receives a context object that allows: |
| 290 | + |
| 291 | +- Emitting operators via `drawOperators()` |
| 292 | +- Registering resources scoped to that pattern/XObject |
| 293 | + |
| 294 | +Resources (fonts, images, etc.) must be embedded on the PDF before being used in the paint callback. |
| 295 | + |
| 296 | +--- |
| 297 | + |
| 298 | +## Implementation Notes |
| 299 | + |
| 300 | +### Shading Functions |
| 301 | + |
| 302 | +PDF shadings require Function objects to define color interpolation. For gradients with two stops, a simple exponential interpolation function suffices. Multi-stop gradients require stitching multiple functions together. This complexity should be hidden from users — they just provide a stops array. |
| 303 | + |
| 304 | +### Pattern Resources |
| 305 | + |
| 306 | +Patterns have their own resource dictionaries. Resources used within a pattern must be registered on the pattern's context, not the page. |
| 307 | + |
| 308 | +### Resource Deduplication |
| 309 | + |
| 310 | +The same resource object registered multiple times on the same page should return the same name. Different pages may assign different names to the same underlying resource. |
| 311 | + |
| 312 | +### Coordinate System |
| 313 | + |
| 314 | +All operators use PDF coordinates (Y-up, origin bottom-left). No automatic coordinate transformation is performed. A future `@libpdf/canvas` package would handle the Y-flip for Canvas2D compatibility. |
| 315 | + |
| 316 | +### Error Handling |
| 317 | + |
| 318 | +- Invalid operator sequences produce invalid PDFs (caller responsibility) |
| 319 | +- Missing resources cause PDF viewer errors at render time |
| 320 | +- TypeScript catches type errors at compile time |
| 321 | + |
| 322 | +--- |
| 323 | + |
| 324 | +## Future: @libpdf/canvas |
| 325 | + |
| 326 | +This API lays the groundwork for a future `@libpdf/canvas` package that would provide a Canvas2D-compatible interface. Such a package would: |
| 327 | + |
| 328 | +- Implement the CanvasRenderingContext2D interface |
| 329 | +- Internally use `page.drawOperators()` and `page.register*()` methods |
| 330 | +- Handle the Y-flip coordinate transform automatically |
| 331 | + |
| 332 | +The core API additions here provide exactly the primitives that package would need. |
| 333 | + |
| 334 | +--- |
| 335 | + |
| 336 | +## Test Plan |
| 337 | + |
| 338 | +### Unit Tests |
| 339 | + |
| 340 | +- Operator emission produces valid PDF content streams |
| 341 | +- Resource registration returns unique names per page |
| 342 | +- Same resource registered twice returns same name (deduplication) |
| 343 | +- Name normalization works (with/without slash) |
| 344 | +- Matrix class composes transforms correctly |
| 345 | +- concatMatrix accepts Matrix or 6 numbers |
| 346 | +- showText accepts string or PdfString |
| 347 | + |
| 348 | +### Integration Tests (with PDF output) |
| 349 | + |
| 350 | +These tests generate PDF files in `fixtures/output/` for visual verification: |
| 351 | + |
| 352 | +- `low-level-operators.pdf` — basic operator emission (rectangles, colors, transforms) |
| 353 | +- `axial-shading.pdf` — linear gradients with 2 and multiple stops |
| 354 | +- `radial-shading.pdf` — radial gradients with various configurations |
| 355 | +- `linear-gradient-angles.pdf` — angle-based convenience method at 0°, 45°, 90°, 180° |
| 356 | +- `tiling-patterns.pdf` — repeating patterns with different step sizes |
| 357 | +- `extgstate-opacity.pdf` — overlapping shapes with varying opacity |
| 358 | +- `extgstate-blend-modes.pdf` — blend mode examples (Multiply, Screen, Overlay) |
| 359 | +- `form-xobjects.pdf` — reusable content stamped at multiple positions |
| 360 | +- `combined-features.pdf` — complex drawing using all features together |
| 361 | + |
| 362 | +### Visual Verification |
| 363 | + |
| 364 | +For visual inspection during development or debugging, convert PDF output to PNG using ImageMagick: |
| 365 | + |
| 366 | +```bash |
| 367 | +# Convert single PDF to PNG (300 DPI) |
| 368 | +magick -density 300 test-output/axial-shading.pdf -quality 100 test-output/axial-shading.png |
| 369 | + |
| 370 | +# Convert specific page of multi-page PDF |
| 371 | +magick -density 300 "test-output/form-xobjects.pdf[0]" test-output/form-xobjects-page1.png |
| 372 | +``` |
| 373 | + |
| 374 | +The PNG files are gitignored but useful for: |
| 375 | + |
| 376 | +- Agent-assisted visual verification during implementation |
| 377 | +- Debugging rendering issues |
| 378 | +- Comparing output across changes |
| 379 | + |
| 380 | +### Manual Verification |
| 381 | + |
| 382 | +Final verification should include opening test PDFs in: |
| 383 | + |
| 384 | +- Adobe Acrobat Reader |
| 385 | +- Chrome's built-in PDF viewer |
| 386 | +- macOS Preview |
| 387 | + |
| 388 | +--- |
| 389 | + |
| 390 | +## Implementation Order |
| 391 | + |
| 392 | +1. **Exports and basics** — expose ops namespace, Matrix class, ColorSpace constants |
| 393 | +2. **Operator improvements** — name normalization, string overload for showText, Matrix overload for concatMatrix |
| 394 | +3. **Core emission** — page.drawOperators() method |
| 395 | +4. **Resource registration** — page.registerFont/Image/ExtGState methods (expose existing internals) |
| 396 | +5. **Shading support** — createAxialShading, createRadialShading, createLinearGradient, registerShading |
| 397 | +6. **Pattern support** — createTilingPattern, registerPattern |
| 398 | +7. **Form XObjects** — createFormXObject, registerXObject |
0 commit comments