Skip to content

Commit 334789d

Browse files
authored
feat(drawing): low-level drawing API with operators, gradients, patterns, and transforms (#15)
Add comprehensive low-level drawing primitives for advanced PDF manipulation: - PathBuilder: fluent API for custom paths with moveTo/lineTo/curveTo/close - Gradient support: linear and radial gradients via PDFShading - Pattern support: tiling patterns and shading patterns (PDFPattern) - ExtGState: opacity, blend modes, and graphics state management - Form XObjects: reusable content blocks (PDFFormXObject) - Resource registration: registerFont/Image/Shading/Pattern/ExtGState/XObject High-level integration: - drawRectangle/drawEllipse/drawLine support pattern fills - createLinearGradient/createRadialGradient convenience methods - createImagePattern for tiled image fills Architecture: - Clean layering: raw operators -> PathBuilder -> high-level methods - Consistent create/register pattern matching PDF resource model - serializeOperators for efficient binary content stream generation
1 parent 98746f0 commit 334789d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+6988
-401
lines changed
Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
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

Comments
 (0)