Skip to content

Commit f2767f7

Browse files
authored
feat: extend roundedRect to allow you to select which corners are rounded (#1698)
* feat: add a new param to roundedRect to allow you to select which corners are rounded * update CHNAGELOG * merge cornerConfig into cornerRadius, allowing individual corner radius values * apply prettier formatting * change property name and order to match css spec
1 parent c8b9ab0 commit f2767f7

4 files changed

Lines changed: 154 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
- Add robust handling of null byte padding in JPEG images
2323
- Replace outdated jpeg-exif with minimal implementation
2424
- Replace outdated crypto-js with maintained small alternatives
25-
- Fix issue with indentation with `indentAllLines: true` when a new page is created
25+
- Fix issue with indentation with `indentAllLines: true` when a new page is created
26+
- Extend `roundedRect` with `borderRadius` as number for all corners or per-corner array (CSS order)
2627

2728
### [v0.17.2] - 2025-08-30
2829

@@ -33,7 +34,7 @@
3334
- Fix null values in table cells rendering as `[object Object]`
3435
- Fix further LineWrapper precision issues
3536
- Optmize standard font handling. Less code, less memory usage
36-
37+
3738
### [v0.17.0] - 2025-04-12
3839

3940
- Fix precision rounding issues in LineWrapper

docs/vector.md

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,16 @@ PDFKit also includes some helpers that make defining common shapes much
5757
easier. Here is a list of the helpers.
5858

5959
* `rect(x, y, width, height)`
60-
* `roundedRect(x, y, width, height, cornerRadius)`
60+
* `roundedRect(x, y, width, height, borderRadius)`
6161
* `ellipse(centerX, centerY, radiusX, radiusY = radiusX)`
6262
* `circle(centerX, centerY, radius)`
6363
* `polygon(points...)`
6464

65+
`roundedRect` `borderRadius` accepts a single radius (number) or per-corner radii (array).
66+
If an array with four values, the order matches CSS `border-radius`:
67+
`/* top-left | top-right | bottom-right | bottom-left */`
68+
For example, to round only the right-side corners: `roundedRect(x, y, w, h, [0, 20, 20, 0])`.
69+
6570
The last one, `polygon`, allows you to pass in a list of points (arrays of x,y
6671
pairs), and it will create the shape by moving to the first point, and then
6772
drawing lines to each consecutive point. Here is how you'd draw a triangle
@@ -86,16 +91,16 @@ path.
8691
In order to make our drawings interesting, we really need to give them some
8792
style. PDFKit has many methods designed to do just that.
8893

89-
* `lineWidth`
90-
* `lineCap`
91-
* `lineJoin`
92-
* `miterLimit`
93-
* `dash`
94-
* `fillColor`
95-
* `strokeColor`
96-
* `opacity`
97-
* `fillOpacity`
98-
* `strokeOpacity`
94+
* `lineWidth`
95+
* `lineCap`
96+
* `lineJoin`
97+
* `miterLimit`
98+
* `dash`
99+
* `fillColor`
100+
* `strokeColor`
101+
* `opacity`
102+
* `fillOpacity`
103+
* `strokeOpacity`
99104

100105
Some of these are pretty self explanatory, but let's go through a few of them.
101106

lib/mixins/vector.js

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -112,24 +112,68 @@ export default {
112112
);
113113
},
114114

115-
roundedRect(x, y, w, h, r) {
116-
if (r == null) {
117-
r = 0;
115+
/**
116+
* @param {number|number[]} borderRadius - Radius (number) or per-corner radii (array).
117+
* If an array, order matches CSS `border-radius` with four values:
118+
* top-left, top-right, bottom-right, bottom-left.
119+
*/
120+
roundedRect(x, y, w, h, borderRadius) {
121+
if (borderRadius == null) {
122+
borderRadius = 0;
118123
}
119-
r = Math.min(r, 0.5 * w, 0.5 * h);
120-
121-
// amount to inset control points from corners (see `ellipse`)
122-
const c = r * (1.0 - KAPPA);
123-
124-
this.moveTo(x + r, y);
125-
this.lineTo(x + w - r, y);
126-
this.bezierCurveTo(x + w - c, y, x + w, y + c, x + w, y + r);
127-
this.lineTo(x + w, y + h - r);
128-
this.bezierCurveTo(x + w, y + h - c, x + w - c, y + h, x + w - r, y + h);
129-
this.lineTo(x + r, y + h);
130-
this.bezierCurveTo(x + c, y + h, x, y + h - c, x, y + h - r);
131-
this.lineTo(x, y + r);
132-
this.bezierCurveTo(x, y + c, x + c, y, x + r, y);
124+
125+
let radii;
126+
if (Array.isArray(borderRadius)) {
127+
radii = borderRadius.slice(0, 4);
128+
} else {
129+
radii = [borderRadius, borderRadius, borderRadius, borderRadius];
130+
}
131+
132+
const limit = Math.min(0.5 * w, 0.5 * h);
133+
const rTL = Math.max(0, Math.min(radii[0] || 0, limit));
134+
const rTR = Math.max(0, Math.min(radii[1] || 0, limit));
135+
const rBR = Math.max(0, Math.min(radii[2] || 0, limit));
136+
const rBL = Math.max(0, Math.min(radii[3] || 0, limit));
137+
138+
const cpTR = rTR * (1.0 - KAPPA);
139+
const cpBR = rBR * (1.0 - KAPPA);
140+
const cpBL = rBL * (1.0 - KAPPA);
141+
const cpTL = rTL * (1.0 - KAPPA);
142+
143+
// Start at the top edge, inset by top-left radius.
144+
this.moveTo(x + rTL, y);
145+
146+
// Top edge to top-right.
147+
this.lineTo(x + w - rTR, y);
148+
if (rTR > 0) {
149+
this.bezierCurveTo(x + w - cpTR, y, x + w, y + cpTR, x + w, y + rTR);
150+
}
151+
152+
// Right edge to bottom-right.
153+
this.lineTo(x + w, y + h - rBR);
154+
if (rBR > 0) {
155+
this.bezierCurveTo(
156+
x + w,
157+
y + h - cpBR,
158+
x + w - cpBR,
159+
y + h,
160+
x + w - rBR,
161+
y + h,
162+
);
163+
}
164+
165+
// Bottom edge to bottom-left.
166+
this.lineTo(x + rBL, y + h);
167+
if (rBL > 0) {
168+
this.bezierCurveTo(x + cpBL, y + h, x, y + h - cpBL, x, y + h - rBL);
169+
}
170+
171+
// Left edge to top-left.
172+
this.lineTo(x, y + rTL);
173+
if (rTL > 0) {
174+
this.bezierCurveTo(x, y + cpTL, x + cpTL, y, x + rTL, y);
175+
}
176+
133177
return this.closePath();
134178
},
135179

tests/unit/vector.spec.js

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import PDFDocument from '../../lib/document';
2-
import { logData } from './helpers';
2+
import { logData, getObjects } from './helpers';
33

44
describe('Vector Graphics', () => {
55
let document;
@@ -176,4 +176,77 @@ describe('Vector Graphics', () => {
176176
]);
177177
});
178178
});
179+
180+
describe('roundedRect', () => {
181+
test('uses borderRadius to draw rounded corners by default', () => {
182+
const docData = logData(document);
183+
184+
document.roundedRect(50, 50, 100, 80, 20).stroke();
185+
document.end();
186+
187+
const objects = getObjects(docData);
188+
const vectorObject = objects.find((obj) =>
189+
obj.items.some((item) => item instanceof Buffer),
190+
);
191+
const streamBuffer =
192+
vectorObject &&
193+
vectorObject.items.find((item) => item instanceof Buffer);
194+
const streamString = streamBuffer?.toString('ascii') || '';
195+
196+
// Expect at least one Bezier curve command (`c`) in the vector stream
197+
expect(streamString).toMatch(/\sc[\s\n]/);
198+
});
199+
200+
test('borderRadius array can disable rounded corners', () => {
201+
const docData = logData(document);
202+
203+
document.roundedRect(50, 50, 100, 80, [0, 0, 0, 0]).stroke();
204+
document.end();
205+
206+
const objects = getObjects(docData);
207+
const vectorObject = objects.find((obj) =>
208+
obj.items.some((item) => item instanceof Buffer),
209+
);
210+
const streamBuffer =
211+
vectorObject &&
212+
vectorObject.items.find((item) => item instanceof Buffer);
213+
const streamString = streamBuffer?.toString('ascii') || '';
214+
215+
// No Bezier curve command (`c`) should be present when all corners are disabled
216+
expect(streamString).not.toMatch(/\sc[\s\n]/);
217+
});
218+
219+
test('top-right corner ends at expected point', () => {
220+
const docData = logData(document);
221+
222+
const x = 10;
223+
const y = 20;
224+
const w = 30;
225+
const r = 5;
226+
227+
// Only the top-right corner is rounded (CSS order: TL, TR, BR, BL)
228+
document.roundedRect(x, y, w, 40, [0, r, 0, 0]).stroke();
229+
document.end();
230+
231+
const objects = getObjects(docData);
232+
const vectorObject = objects.find((obj) =>
233+
obj.items.some((item) => item instanceof Buffer),
234+
);
235+
const streamBuffer =
236+
vectorObject &&
237+
vectorObject.items.find((item) => item instanceof Buffer);
238+
const streamString = streamBuffer?.toString('ascii') || '';
239+
240+
const expectedX = x + w; // 40
241+
const expectedY = y + r; // 25
242+
243+
// Look for a cubic Bezier segment whose end point is (expectedX, expectedY)
244+
// Numbers are written using PDFObject.number, but these coordinates are integers.
245+
const endPointPattern = new RegExp(
246+
`\\b${expectedX}(?:\\.0+)?\\s+${expectedY}(?:\\.0+)?\\s+c\\b`,
247+
);
248+
249+
expect(streamString).toMatch(endPointPattern);
250+
});
251+
});
179252
});

0 commit comments

Comments
 (0)