From 4045815c3c41a252155e4aa0de14bff63db63849 Mon Sep 17 00:00:00 2001 From: Tristan Blackwell Date: Tue, 10 Mar 2026 12:30:28 +0000 Subject: [PATCH 1/4] feat: add a new param to roundedRect to allow you to select which corners are rounded --- docs/vector.md | 22 +++++------ lib/mixins/vector.js | 77 ++++++++++++++++++++++++++++++++------- tests/unit/vector.spec.js | 72 +++++++++++++++++++++++++++++++++++- 3 files changed, 146 insertions(+), 25 deletions(-) diff --git a/docs/vector.md b/docs/vector.md index 07138d651..206157249 100644 --- a/docs/vector.md +++ b/docs/vector.md @@ -57,7 +57,7 @@ PDFKit also includes some helpers that make defining common shapes much easier. Here is a list of the helpers. * `rect(x, y, width, height)` -* `roundedRect(x, y, width, height, cornerRadius)` +* `roundedRect(x, y, width, height, cornerRadius, cornerConfig)` * `ellipse(centerX, centerY, radiusX, radiusY = radiusX)` * `circle(centerX, centerY, radius)` * `polygon(points...)` @@ -86,16 +86,16 @@ path. In order to make our drawings interesting, we really need to give them some style. PDFKit has many methods designed to do just that. - * `lineWidth` - * `lineCap` - * `lineJoin` - * `miterLimit` - * `dash` - * `fillColor` - * `strokeColor` - * `opacity` - * `fillOpacity` - * `strokeOpacity` +* `lineWidth` +* `lineCap` +* `lineJoin` +* `miterLimit` +* `dash` +* `fillColor` +* `strokeColor` +* `opacity` +* `fillOpacity` +* `strokeOpacity` Some of these are pretty self explanatory, but let's go through a few of them. diff --git a/lib/mixins/vector.js b/lib/mixins/vector.js index b95ecfa98..05d4a861d 100644 --- a/lib/mixins/vector.js +++ b/lib/mixins/vector.js @@ -112,24 +112,75 @@ export default { ); }, - roundedRect(x, y, w, h, r) { + /** + * @param {string} cornerConfig - Specify which corners should have a radius applied. + * Default '1111'. '0000' would mean no corners would have a radius. Order of bits are: + * top right, bottom right, bottom left and top left. + */ + roundedRect(x, y, w, h, r, cornerConfig) { if (r == null) { r = 0; } r = Math.min(r, 0.5 * w, 0.5 * h); - + if (!cornerConfig) { + cornerConfig = '1111'; + } // amount to inset control points from corners (see `ellipse`) - const c = r * (1.0 - KAPPA); - - this.moveTo(x + r, y); - this.lineTo(x + w - r, y); - this.bezierCurveTo(x + w - c, y, x + w, y + c, x + w, y + r); - this.lineTo(x + w, y + h - r); - this.bezierCurveTo(x + w, y + h - c, x + w - c, y + h, x + w - r, y + h); - this.lineTo(x + r, y + h); - this.bezierCurveTo(x + c, y + h, x, y + h - c, x, y + h - r); - this.lineTo(x, y + r); - this.bezierCurveTo(x, y + c, x + c, y, x + r, y); + const cpOffset = r * (1.0 - KAPPA); + if (cornerConfig[0] === '1') { + this.moveTo(x + r, y); + this.lineTo(x + w - r, y); + this.bezierCurveTo( + x + w - cpOffset, + y, + x + w, + y + cpOffset, + x + w, + y + r, + ); + } else { + this.moveTo(x, y); + this.lineTo(x + w, y); + } + if (cornerConfig[1] === '1') { + this.lineTo(x + w, y + h - r); + this.bezierCurveTo( + x + w, + y + h - cpOffset, + x + w - cpOffset, + y + h, + x + w - r, + y + h, + ); + } else { + this.lineTo(x + w, y + h); + } + if (cornerConfig[2] === '1') { + this.lineTo(x + r, y + h); + this.bezierCurveTo( + x + cpOffset, + y + h, + x, + y + h - cpOffset, + x, + y + h - r, + ); + } else { + this.lineTo(x, y + h); + } + if (cornerConfig[3] === '1') { + this.lineTo(x, y + r); + this.bezierCurveTo( + x, + y + cpOffset, + x + cpOffset, + y, + x + r, + y, + ); + } else { + this.lineTo(x, y); + } return this.closePath(); }, diff --git a/tests/unit/vector.spec.js b/tests/unit/vector.spec.js index 7e2dd0c30..b2f349781 100644 --- a/tests/unit/vector.spec.js +++ b/tests/unit/vector.spec.js @@ -1,5 +1,5 @@ import PDFDocument from '../../lib/document'; -import { logData } from './helpers'; +import { logData, getObjects } from './helpers'; describe('Vector Graphics', () => { let document; @@ -176,4 +176,74 @@ describe('Vector Graphics', () => { ]); }); }); + + describe('roundedRect', () => { + test('uses cornerRadius to draw rounded corners by default', () => { + const docData = logData(document); + + document.roundedRect(50, 50, 100, 80, 20).stroke(); + document.end(); + + const objects = getObjects(docData); + const vectorObject = objects.find((obj) => + obj.items.some((item) => item instanceof Buffer), + ); + const streamBuffer = + vectorObject && vectorObject.items.find((item) => item instanceof Buffer); + const streamString = streamBuffer?.toString('ascii') || ''; + + // Expect at least one Bezier curve command (`c`) in the vector stream + expect(streamString).toMatch(/\sc[\s\n]/); + }); + + test('cornerConfig can disable rounded corners', () => { + const docData = logData(document); + + document.roundedRect(50, 50, 100, 80, 20, '0000').stroke(); + document.end(); + + const objects = getObjects(docData); + const vectorObject = objects.find((obj) => + obj.items.some((item) => item instanceof Buffer), + ); + const streamBuffer = + vectorObject && vectorObject.items.find((item) => item instanceof Buffer); + const streamString = streamBuffer?.toString('ascii') || ''; + + // No Bezier curve command (`c`) should be present when all corners are disabled + expect(streamString).not.toMatch(/\sc[\s\n]/); + }); + + test('top-right corner ends at expected point', () => { + const docData = logData(document); + + const x = 10; + const y = 20; + const w = 30; + const r = 5; + + // Only the top-right corner is rounded + document.roundedRect(x, y, w, 40, r, '1000').stroke(); + document.end(); + + const objects = getObjects(docData); + const vectorObject = objects.find((obj) => + obj.items.some((item) => item instanceof Buffer), + ); + const streamBuffer = + vectorObject && vectorObject.items.find((item) => item instanceof Buffer); + const streamString = streamBuffer?.toString('ascii') || ''; + + const expectedX = x + w; // 40 + const expectedY = y + r; // 25 + + // Look for a cubic Bezier segment whose end point is (expectedX, expectedY) + // Numbers are written using PDFObject.number, but these coordinates are integers. + const endPointPattern = new RegExp( + `\\b${expectedX}(?:\\.0+)?\\s+${expectedY}(?:\\.0+)?\\s+c\\b`, + ); + + expect(streamString).toMatch(endPointPattern); + }); + }); }); From 480c2e9064eb4066525b7f89be47980dfa7ad8df Mon Sep 17 00:00:00 2001 From: Tristan Blackwell Date: Tue, 10 Mar 2026 12:34:32 +0000 Subject: [PATCH 2/4] update CHNAGELOG --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69a5f3fc9..40880f74c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ - Add robust handling of null byte padding in JPEG images - Replace outdated jpeg-exif with minimal implementation - Replace outdated crypto-js with maintained small alternatives -- Fix issue with indentation with `indentAllLines: true` when a new page is created +- Fix issue with indentation with `indentAllLines: true` when a new page is created +- Extend roundedRect with a new param that allows you to optionally configure rounded corner selection ### [v0.17.2] - 2025-08-30 @@ -25,7 +26,7 @@ - Fix null values in table cells rendering as `[object Object]` - Fix further LineWrapper precision issues - Optmize standard font handling. Less code, less memory usage - + ### [v0.17.0] - 2025-04-12 - Fix precision rounding issues in LineWrapper From d633792c9279f153ce302c8c9a8a443576d334a9 Mon Sep 17 00:00:00 2001 From: Tristan Blackwell Date: Wed, 11 Mar 2026 08:38:15 +0000 Subject: [PATCH 3/4] merge cornerConfig into cornerRadius, allowing individual corner radius values --- CHANGELOG.md | 2 +- docs/vector.md | 6 ++- lib/mixins/vector.js | 100 ++++++++++++++++++++++---------------- tests/unit/vector.spec.js | 6 +-- 4 files changed, 68 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40880f74c..ad63fb06b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ - Replace outdated jpeg-exif with minimal implementation - Replace outdated crypto-js with maintained small alternatives - Fix issue with indentation with `indentAllLines: true` when a new page is created -- Extend roundedRect with a new param that allows you to optionally configure rounded corner selection +- Extend cornerRadius param to accept an array of radii ### [v0.17.2] - 2025-08-30 diff --git a/docs/vector.md b/docs/vector.md index 206157249..c6a63a1ff 100644 --- a/docs/vector.md +++ b/docs/vector.md @@ -57,11 +57,15 @@ PDFKit also includes some helpers that make defining common shapes much easier. Here is a list of the helpers. * `rect(x, y, width, height)` -* `roundedRect(x, y, width, height, cornerRadius, cornerConfig)` +* `roundedRect(x, y, width, height, cornerRadius)` * `ellipse(centerX, centerY, radiusX, radiusY = radiusX)` * `circle(centerX, centerY, radius)` * `polygon(points...)` +`roundedRect` `cornerRadius` param accepts a single radius (number) or per-corner radii (array). +If an array, the order is: `[topRight, bottomRight, bottomLeft, topLeft]`. +For example, to round only the right side corners: `roundedRect(x, y, w, h, [20, 20, 0, 0])`. + The last one, `polygon`, allows you to pass in a list of points (arrays of x,y pairs), and it will create the shape by moving to the first point, and then drawing lines to each consecutive point. Here is how you'd draw a triangle diff --git a/lib/mixins/vector.js b/lib/mixins/vector.js index 05d4a861d..b74978bd7 100644 --- a/lib/mixins/vector.js +++ b/lib/mixins/vector.js @@ -113,74 +113,92 @@ export default { }, /** - * @param {string} cornerConfig - Specify which corners should have a radius applied. - * Default '1111'. '0000' would mean no corners would have a radius. Order of bits are: - * top right, bottom right, bottom left and top left. + * @param {number|number[]} cornerRadius - Corner radius (number) or per-corner radii (array). + * If an array, order is: top-right, bottom-right, bottom-left, top-left. */ - roundedRect(x, y, w, h, r, cornerConfig) { - if (r == null) { - r = 0; + roundedRect(x, y, w, h, cornerRadius) { + if (cornerRadius == null) { + cornerRadius = 0; } - r = Math.min(r, 0.5 * w, 0.5 * h); - if (!cornerConfig) { - cornerConfig = '1111'; + + let radii; + if (Array.isArray(cornerRadius)) { + radii = cornerRadius.slice(0, 4); + } else { + radii = [ + cornerRadius, // top-right + cornerRadius, // bottom-right + cornerRadius, // bottom-left + cornerRadius, // top-left + ]; } - // amount to inset control points from corners (see `ellipse`) - const cpOffset = r * (1.0 - KAPPA); - if (cornerConfig[0] === '1') { - this.moveTo(x + r, y); - this.lineTo(x + w - r, y); + + const limit = Math.min(0.5 * w, 0.5 * h); + const rTR = Math.max(0, Math.min(radii[0] || 0, limit)); + const rBR = Math.max(0, Math.min(radii[1] || 0, limit)); + const rBL = Math.max(0, Math.min(radii[2] || 0, limit)); + const rTL = Math.max(0, Math.min(radii[3] || 0, limit)); + + const cpTR = rTR * (1.0 - KAPPA); + const cpBR = rBR * (1.0 - KAPPA); + const cpBL = rBL * (1.0 - KAPPA); + const cpTL = rTL * (1.0 - KAPPA); + + // Start at the top edge, inset by top-left radius. + this.moveTo(x + rTL, y); + + // Top edge to top-right. + this.lineTo(x + w - rTR, y); + if (rTR > 0) { this.bezierCurveTo( - x + w - cpOffset, + x + w - cpTR, y, x + w, - y + cpOffset, + y + cpTR, x + w, - y + r, + y + rTR, ); - } else { - this.moveTo(x, y); - this.lineTo(x + w, y); } - if (cornerConfig[1] === '1') { - this.lineTo(x + w, y + h - r); + + // Right edge to bottom-right. + this.lineTo(x + w, y + h - rBR); + if (rBR > 0) { this.bezierCurveTo( x + w, - y + h - cpOffset, - x + w - cpOffset, + y + h - cpBR, + x + w - cpBR, y + h, - x + w - r, + x + w - rBR, y + h, ); - } else { - this.lineTo(x + w, y + h); } - if (cornerConfig[2] === '1') { - this.lineTo(x + r, y + h); + + // Bottom edge to bottom-left. + this.lineTo(x + rBL, y + h); + if (rBL > 0) { this.bezierCurveTo( - x + cpOffset, + x + cpBL, y + h, x, - y + h - cpOffset, + y + h - cpBL, x, - y + h - r, + y + h - rBL, ); - } else { - this.lineTo(x, y + h); } - if (cornerConfig[3] === '1') { - this.lineTo(x, y + r); + + // Left edge to top-left. + this.lineTo(x, y + rTL); + if (rTL > 0) { this.bezierCurveTo( x, - y + cpOffset, - x + cpOffset, + y + cpTL, + x + cpTL, y, - x + r, + x + rTL, y, ); - } else { - this.lineTo(x, y); } + return this.closePath(); }, diff --git a/tests/unit/vector.spec.js b/tests/unit/vector.spec.js index b2f349781..5f4450eae 100644 --- a/tests/unit/vector.spec.js +++ b/tests/unit/vector.spec.js @@ -196,10 +196,10 @@ describe('Vector Graphics', () => { expect(streamString).toMatch(/\sc[\s\n]/); }); - test('cornerConfig can disable rounded corners', () => { + test('cornerRadius array can disable rounded corners', () => { const docData = logData(document); - document.roundedRect(50, 50, 100, 80, 20, '0000').stroke(); + document.roundedRect(50, 50, 100, 80, [0, 0, 0, 0]).stroke(); document.end(); const objects = getObjects(docData); @@ -223,7 +223,7 @@ describe('Vector Graphics', () => { const r = 5; // Only the top-right corner is rounded - document.roundedRect(x, y, w, 40, r, '1000').stroke(); + document.roundedRect(x, y, w, 40, [r, 0, 0, 0]).stroke(); document.end(); const objects = getObjects(docData); From 3050110f674234119a0874cd352308d5435f6cfa Mon Sep 17 00:00:00 2001 From: Tristan Blackwell Date: Wed, 11 Mar 2026 08:43:06 +0000 Subject: [PATCH 4/4] apply prettier formatting --- lib/mixins/vector.js | 27 +++------------------------ tests/unit/vector.spec.js | 9 ++++++--- 2 files changed, 9 insertions(+), 27 deletions(-) diff --git a/lib/mixins/vector.js b/lib/mixins/vector.js index b74978bd7..9c5f14d14 100644 --- a/lib/mixins/vector.js +++ b/lib/mixins/vector.js @@ -150,14 +150,7 @@ export default { // Top edge to top-right. this.lineTo(x + w - rTR, y); if (rTR > 0) { - this.bezierCurveTo( - x + w - cpTR, - y, - x + w, - y + cpTR, - x + w, - y + rTR, - ); + this.bezierCurveTo(x + w - cpTR, y, x + w, y + cpTR, x + w, y + rTR); } // Right edge to bottom-right. @@ -176,27 +169,13 @@ export default { // Bottom edge to bottom-left. this.lineTo(x + rBL, y + h); if (rBL > 0) { - this.bezierCurveTo( - x + cpBL, - y + h, - x, - y + h - cpBL, - x, - y + h - rBL, - ); + this.bezierCurveTo(x + cpBL, y + h, x, y + h - cpBL, x, y + h - rBL); } // Left edge to top-left. this.lineTo(x, y + rTL); if (rTL > 0) { - this.bezierCurveTo( - x, - y + cpTL, - x + cpTL, - y, - x + rTL, - y, - ); + this.bezierCurveTo(x, y + cpTL, x + cpTL, y, x + rTL, y); } return this.closePath(); diff --git a/tests/unit/vector.spec.js b/tests/unit/vector.spec.js index 5f4450eae..8d762afc9 100644 --- a/tests/unit/vector.spec.js +++ b/tests/unit/vector.spec.js @@ -189,7 +189,8 @@ describe('Vector Graphics', () => { obj.items.some((item) => item instanceof Buffer), ); const streamBuffer = - vectorObject && vectorObject.items.find((item) => item instanceof Buffer); + vectorObject && + vectorObject.items.find((item) => item instanceof Buffer); const streamString = streamBuffer?.toString('ascii') || ''; // Expect at least one Bezier curve command (`c`) in the vector stream @@ -207,7 +208,8 @@ describe('Vector Graphics', () => { obj.items.some((item) => item instanceof Buffer), ); const streamBuffer = - vectorObject && vectorObject.items.find((item) => item instanceof Buffer); + vectorObject && + vectorObject.items.find((item) => item instanceof Buffer); const streamString = streamBuffer?.toString('ascii') || ''; // No Bezier curve command (`c`) should be present when all corners are disabled @@ -231,7 +233,8 @@ describe('Vector Graphics', () => { obj.items.some((item) => item instanceof Buffer), ); const streamBuffer = - vectorObject && vectorObject.items.find((item) => item instanceof Buffer); + vectorObject && + vectorObject.items.find((item) => item instanceof Buffer); const streamString = streamBuffer?.toString('ascii') || ''; const expectedX = x + w; // 40