diff --git a/CHANGELOG.md b/CHANGELOG.md index 69a5f3fc..ad63fb06 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 cornerRadius param to accept an array of radii ### [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 diff --git a/docs/vector.md b/docs/vector.md index 07138d65..c6a63a1f 100644 --- a/docs/vector.md +++ b/docs/vector.md @@ -62,6 +62,10 @@ easier. Here is a list of the helpers. * `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 @@ -86,16 +90,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 b95ecfa9..9c5f14d1 100644 --- a/lib/mixins/vector.js +++ b/lib/mixins/vector.js @@ -112,24 +112,72 @@ export default { ); }, - roundedRect(x, y, w, h, r) { - if (r == null) { - r = 0; + /** + * @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, cornerRadius) { + if (cornerRadius == null) { + cornerRadius = 0; } - r = Math.min(r, 0.5 * w, 0.5 * h); - - // 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); + + 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 + ]; + } + + 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 - cpTR, y, x + w, y + cpTR, x + w, y + rTR); + } + + // Right edge to bottom-right. + this.lineTo(x + w, y + h - rBR); + if (rBR > 0) { + this.bezierCurveTo( + x + w, + y + h - cpBR, + x + w - cpBR, + y + h, + x + w - rBR, + y + h, + ); + } + + // 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); + } + + // Left edge to top-left. + this.lineTo(x, y + rTL); + if (rTL > 0) { + 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 7e2dd0c3..8d762afc 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,77 @@ 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('cornerRadius array can disable rounded corners', () => { + const docData = logData(document); + + document.roundedRect(50, 50, 100, 80, [0, 0, 0, 0]).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, 0, 0, 0]).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); + }); + }); });