diff --git a/CHANGELOG.md b/CHANGELOG.md index b4ceeb1c..27cbacdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Bump node version requirement to 20+ - Bump minimum supported browsers to Firefox 115, iOS/Safari 16 - Fix text with input x as null +- Add opacity option to `doc.image()` to control image transparency - Fix PDF/UA compliance issues in kitchen-sink-accessible example - Add bbox and placement options to PDFStructureElement for PDF/UA compliance diff --git a/docs/images.md b/docs/images.md index 2e1723b7..8e9ec6ec 100644 --- a/docs/images.md +++ b/docs/images.md @@ -18,6 +18,7 @@ be scaled according to the following options. - `goTo` - go to anchor (shortcut to create an annotation) - `destination` - create anchor to this image - `ignoreOrientation` - (true/false) ignore JPEG EXIF orientation. By default, images with JPEG EXIF orientation are properly rotated and/or flipped. Defaults to `false`, unless `ignoreOrientation` option set to `true` when creating the `PDFDocument` object (e.g. `new PDFDocument({ignoreOrientation: true})`) +- `opacity` - a value between `0` (fully transparent) and `1` (fully opaque). For PNGs that already have an alpha channel, this compounds with the existing transparency When a `fit` or `cover` array is provided, PDFKit accepts these additional options: diff --git a/lib/mixins/images.js b/lib/mixins/images.js index 8486b9b4..ff234a8e 100644 --- a/lib/mixins/images.js +++ b/lib/mixins/images.js @@ -209,6 +209,10 @@ export default { this.save(); + if (options.opacity != null) { + this._doOpacity(options.opacity, null); + } + if (rotateAngle) { this.rotate(rotateAngle, { origin: [originX, originY], diff --git a/tests/unit/image.spec.js b/tests/unit/image.spec.js index af6e4cf1..24a1ca5e 100644 --- a/tests/unit/image.spec.js +++ b/tests/unit/image.spec.js @@ -28,4 +28,58 @@ describe('Image', function () { expect(jpeg.height).toBe(500); expect(jpeg.orientation).toBe(1); }); + + describe('opacity', function () { + test('adds an ExtGState with the correct ca value', () => { + document.image('./tests/images/bee.png', 0, 0, { opacity: 0.5 }); + + const gstates = document.page.ext_gstates; + const entry = Object.values(gstates)[0]; + expect(entry.data.ca).toBe(0.5); + }); + + test('registers the ExtGState on the page resources', () => { + document.image('./tests/images/bee.png', 0, 0, { opacity: 0.5 }); + + expect(Object.keys(document.page.ext_gstates).length).toBe(1); + }); + + test('clamps opacity below 0 to 0', () => { + document.image('./tests/images/bee.png', 0, 0, { opacity: -0.5 }); + + const entry = Object.values(document.page.ext_gstates)[0]; + expect(entry.data.ca).toBe(0); + }); + + test('clamps opacity above 1 to 1', () => { + document.image('./tests/images/bee.png', 0, 0, { opacity: 1.5 }); + + const entry = Object.values(document.page.ext_gstates)[0]; + expect(entry.data.ca).toBe(1); + }); + + test('reuses the same ExtGState for the same opacity value', () => { + document.image('./tests/images/bee.png', 0, 0, { opacity: 0.5 }); + document.image('./tests/images/bee.png', 100, 0, { opacity: 0.5 }); + + // both calls share one entry, not two + expect(Object.keys(document.page.ext_gstates).length).toBe(1); + }); + + test('does not add an ExtGState when no opacity is specified', () => { + document.image('./tests/images/bee.png', 0, 0); + + expect(Object.keys(document.page.ext_gstates).length).toBe(0); + }); + + test('links the ExtGState into the page resources', () => { + document.image('./tests/images/bee.png', 0, 0, { opacity: 0.5 }); + document.end(); + + const gstates = document.page.ext_gstates; + const [name, ref] = Object.entries(gstates)[0]; + expect(name).toMatch(/^Gs\d+$/); + expect(ref.data.ca).toBe(0.5); + }); + }); });