From 4e3f3792adc5dd00843668ed8ef326365163d609 Mon Sep 17 00:00:00 2001 From: Thibaut Patel Date: Wed, 11 Jun 2025 21:24:09 +0200 Subject: [PATCH] Add keepXmp and withXmp for control over output XMP metadata #4416 --- docs/public/humans.txt | 3 + docs/src/content/docs/api-output.md | 51 +++++++++ docs/src/content/docs/changelog.md | 4 + lib/constructor.js | 1 + lib/index.d.ts | 16 ++- lib/output.js | 55 ++++++++++ src/pipeline.cc | 8 +- src/pipeline.h | 1 + test/types/sharp.test-d.ts | 2 + test/unit/metadata.js | 164 ++++++++++++++++++++++++++++ 10 files changed, 303 insertions(+), 2 deletions(-) diff --git a/docs/public/humans.txt b/docs/public/humans.txt index 01af76f0..327f5532 100644 --- a/docs/public/humans.txt +++ b/docs/public/humans.txt @@ -320,3 +320,6 @@ GitHub: https://github.com/qpincon Name: Hans Chen GitHub: https://github.com/hans00 + +Name: Thibaut Patel +GitHub: https://github.com/tpatel diff --git a/docs/src/content/docs/api-output.md b/docs/src/content/docs/api-output.md index 40cc74cf..7c966fd7 100644 --- a/docs/src/content/docs/api-output.md +++ b/docs/src/content/docs/api-output.md @@ -242,6 +242,57 @@ const outputWithP3 = await sharp(input) ``` +## keepXmp +> keepXmp() ⇒ Sharp + +Keep XMP metadata from the input image in the output image. + + +**Since**: 0.34.3 +**Example** +```js +const outputWithXmp = await sharp(inputWithXmp) + .keepXmp() + .toBuffer(); +``` + + +## withXmp +> withXmp(xmp) ⇒ Sharp + +Set XMP metadata in the output image. + +Supported by PNG, JPEG, WebP, and TIFF output. + + +**Throws**: + +- Error Invalid parameters + +**Since**: 0.34.3 + +| Param | Type | Description | +| --- | --- | --- | +| xmp | string | String containing XMP metadata to be embedded in the output image. | + +**Example** +```js +const xmpString = ` + + + + + John Doe + + + `; + +const data = await sharp(input) + .withXmp(xmpString) + .toBuffer(); +``` + + ## keepMetadata > keepMetadata() ⇒ Sharp diff --git a/docs/src/content/docs/changelog.md b/docs/src/content/docs/changelog.md index de7d34ca..92f48320 100644 --- a/docs/src/content/docs/changelog.md +++ b/docs/src/content/docs/changelog.md @@ -31,6 +31,10 @@ Requires libvips v8.17.0 [#4412](https://github.com/lovell/sharp/pull/4412) [@kleisauke](https://github.com/kleisauke) +* Add `keepXmp` and `withXmp` for control over output XMP metadata. + [#4416](https://github.com/lovell/sharp/pull/4416) + [@tpatel](https://github.com/tpatel) + ### v0.34.2 - 20th May 2025 * Ensure animated GIF to WebP conversion retains loop (regression in 0.34.0). diff --git a/lib/constructor.js b/lib/constructor.js index e5d2a0c5..131a21a4 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -306,6 +306,7 @@ const Sharp = function (input, options) { withIccProfile: '', withExif: {}, withExifMerge: true, + withXmp: '', resolveWithObject: false, loop: -1, delay: [], diff --git a/lib/index.d.ts b/lib/index.d.ts index ab7825c5..90de835e 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -419,7 +419,7 @@ declare namespace sharp { * @returns {Sharp} */ autoOrient(): Sharp - + /** * Flip the image about the vertical Y axis. This always occurs after rotation, if any. * The use of flip implies the removal of the EXIF Orientation tag, if any. @@ -730,6 +730,20 @@ declare namespace sharp { */ withIccProfile(icc: string, options?: WithIccProfileOptions): Sharp; + /** + * Keep all XMP metadata from the input image in the output image. + * @returns A sharp instance that can be used to chain operations + */ + keepXmp(): Sharp; + + /** + * Set XMP metadata in the output image. + * @param {string} xmp - String containing XMP metadata to be embedded in the output image. + * @returns A sharp instance that can be used to chain operations + * @throws {Error} Invalid parameters + */ + withXmp(xmp: string): Sharp; + /** * Include all metadata (EXIF, XMP, IPTC) from the input image in the output image. * The default behaviour, when withMetadata is not used, is to strip all metadata and convert to the device-independent sRGB colour space. diff --git a/lib/output.js b/lib/output.js index a8b32277..f1cdaf84 100644 --- a/lib/output.js +++ b/lib/output.js @@ -312,6 +312,59 @@ function withIccProfile (icc, options) { return this; } +/** + * Keep XMP metadata from the input image in the output image. + * + * @since 0.34.3 + * + * @example + * const outputWithXmp = await sharp(inputWithXmp) + * .keepXmp() + * .toBuffer(); + * + * @returns {Sharp} + */ +function keepXmp () { + this.options.keepMetadata |= 0b00010; + return this; +} + +/** + * Set XMP metadata in the output image. + * + * Supported by PNG, JPEG, WebP, and TIFF output. + * + * @since 0.34.3 + * + * @example + * const xmpString = ` + * + * + * + * + * John Doe + * + * + * `; + * + * const data = await sharp(input) + * .withXmp(xmpString) + * .toBuffer(); + * + * @param {string} xmp String containing XMP metadata to be embedded in the output image. + * @returns {Sharp} + * @throws {Error} Invalid parameters + */ +function withXmp (xmp) { + if (is.string(xmp) && xmp.length > 0) { + this.options.withXmp = xmp; + this.options.keepMetadata |= 0b00010; + } else { + throw is.invalidParameterError('xmp', 'non-empty string', xmp); + } + return this; +} + /** * Keep all metadata (EXIF, ICC, XMP, IPTC) from the input image in the output image. * @@ -1576,6 +1629,8 @@ module.exports = function (Sharp) { withExifMerge, keepIccProfile, withIccProfile, + keepXmp, + withXmp, keepMetadata, withMetadata, toFormat, diff --git a/src/pipeline.cc b/src/pipeline.cc index bddec2ba..3aa526f3 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -876,7 +876,12 @@ class PipelineWorker : public Napi::AsyncWorker { image.set(s.first.data(), s.second.data()); } } - + // XMP buffer + if ((baton->keepMetadata & VIPS_FOREIGN_KEEP_XMP) && !baton->withXmp.empty()) { + image = image.copy(); + image.set(VIPS_META_XMP_NAME, nullptr, + const_cast(static_cast(baton->withXmp.c_str())), baton->withXmp.size()); + } // Number of channels used in output image baton->channels = image.bands(); baton->width = image.width(); @@ -1706,6 +1711,7 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) { } } baton->withExifMerge = sharp::AttrAsBool(options, "withExifMerge"); + baton->withXmp = sharp::AttrAsStr(options, "withXmp"); baton->timeoutSeconds = sharp::AttrAsUint32(options, "timeoutSeconds"); baton->loop = sharp::AttrAsUint32(options, "loop"); baton->delay = sharp::AttrAsInt32Vector(options, "delay"); diff --git a/src/pipeline.h b/src/pipeline.h index 63c9f7c2..d5d5b3fb 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -202,6 +202,7 @@ struct PipelineBaton { std::string withIccProfile; std::unordered_map withExif; bool withExifMerge; + std::string withXmp; int timeoutSeconds; std::vector convKernel; int convKernelWidth; diff --git a/test/types/sharp.test-d.ts b/test/types/sharp.test-d.ts index 2d65eb49..27e5ec46 100644 --- a/test/types/sharp.test-d.ts +++ b/test/types/sharp.test-d.ts @@ -692,6 +692,8 @@ sharp(input) k2: 'v2' } }) + .keepXmp() + .withXmp('test') .keepIccProfile() .withIccProfile('filename') .withIccProfile('filename', { attach: false }); diff --git a/test/unit/metadata.js b/test/unit/metadata.js index b5bb5e59..ccf7e86e 100644 --- a/test/unit/metadata.js +++ b/test/unit/metadata.js @@ -1100,6 +1100,170 @@ describe('Image metadata', function () { assert.strictEqual(exif2.Image.Software, 'sharp'); }); + describe('XMP metadata tests', function () { + it('withMetadata preserves existing XMP metadata from input', async () => { + const data = await sharp(fixtures.inputJpgWithIptcAndXmp) + .resize(320, 240) + .withMetadata() + .toBuffer(); + + const metadata = await sharp(data).metadata(); + assert.strictEqual('object', typeof metadata.xmp); + assert.strictEqual(true, metadata.xmp instanceof Buffer); + assert.strictEqual(true, metadata.xmp.length > 0); + // Check that XMP starts with the expected XML declaration + assert.strictEqual(metadata.xmp.indexOf(Buffer.from(' { + const data = await sharp(fixtures.inputJpgWithIptcAndXmp) + .resize(320, 240) + .keepXmp() + .toBuffer(); + + const metadata = await sharp(data).metadata(); + assert.strictEqual('object', typeof metadata.xmp); + assert.strictEqual(true, metadata.xmp instanceof Buffer); + assert.strictEqual(true, metadata.xmp.length > 0); + // Check that XMP starts with the expected XML declaration + assert.strictEqual(metadata.xmp.indexOf(Buffer.from(' { + const customXmp = 'Test CreatorTest Title'; + + const data = await sharp(fixtures.inputJpgWithIptcAndXmp) + .resize(320, 240) + .withXmp(customXmp) + .toBuffer(); + + const metadata = await sharp(data).metadata(); + assert.strictEqual('object', typeof metadata.xmp); + assert.strictEqual(true, metadata.xmp instanceof Buffer); + + // Check that the XMP contains our custom content + const xmpString = metadata.xmp.toString(); + assert.strictEqual(true, xmpString.includes('Test Creator')); + assert.strictEqual(true, xmpString.includes('Test Title')); + }); + + it('withXmp with custom XMP buffer on image without existing XMP', async () => { + const customXmp = 'Added via Sharp'; + + const data = await sharp(fixtures.inputJpg) + .resize(320, 240) + .withXmp(customXmp) + .toBuffer(); + + const metadata = await sharp(data).metadata(); + assert.strictEqual('object', typeof metadata.xmp); + assert.strictEqual(true, metadata.xmp instanceof Buffer); + + // Check that the XMP contains our custom content + const xmpString = metadata.xmp.toString(); + assert.strictEqual(true, xmpString.includes('Added via Sharp')); + }); + + it('withXmp with valid XMP metadata for different image formats', async () => { + const customXmp = 'testmetadata'; + + // Test with JPEG output + const jpegData = await sharp(fixtures.inputJpg) + .resize(100, 100) + .jpeg() + .withXmp(customXmp) + .toBuffer(); + + const jpegMetadata = await sharp(jpegData).metadata(); + assert.strictEqual('object', typeof jpegMetadata.xmp); + assert.strictEqual(true, jpegMetadata.xmp instanceof Buffer); + assert.strictEqual(true, jpegMetadata.xmp.toString().includes('test')); + + // Test with PNG output (PNG should also support XMP metadata) + const pngData = await sharp(fixtures.inputJpg) + .resize(100, 100) + .png() + .withXmp(customXmp) + .toBuffer(); + + const pngMetadata = await sharp(pngData).metadata(); + // PNG format should preserve XMP metadata when using withXmp + assert.strictEqual('object', typeof pngMetadata.xmp); + assert.strictEqual(true, pngMetadata.xmp instanceof Buffer); + assert.strictEqual(true, pngMetadata.xmp.toString().includes('test')); + + // Test with WebP output (WebP should also support XMP metadata) + const webpData = await sharp(fixtures.inputJpg) + .resize(100, 100) + .webp() + .withXmp(customXmp) + .toBuffer(); + + const webpMetadata = await sharp(webpData).metadata(); + // WebP format should preserve XMP metadata when using withXmp + assert.strictEqual('object', typeof webpMetadata.xmp); + assert.strictEqual(true, webpMetadata.xmp instanceof Buffer); + assert.strictEqual(true, webpMetadata.xmp.toString().includes('test')); + }); + + it('XMP metadata persists through multiple operations', async () => { + const customXmp = 'persistent-test'; + + const data = await sharp(fixtures.inputJpg) + .resize(320, 240) + .withXmp(customXmp) + .rotate(90) + .blur(1) + .sharpen() + .toBuffer(); + + const metadata = await sharp(data).metadata(); + assert.strictEqual('object', typeof metadata.xmp); + assert.strictEqual(true, metadata.xmp instanceof Buffer); + assert.strictEqual(true, metadata.xmp.toString().includes('persistent-test')); + }); + + it('withXmp XMP works with WebP format specifically', async () => { + const webpXmp = 'WebP Creatorimage/webp'; + + const data = await sharp(fixtures.inputJpg) + .resize(120, 80) + .webp({ quality: 80 }) + .withXmp(webpXmp) + .toBuffer(); + + const metadata = await sharp(data).metadata(); + assert.strictEqual('webp', metadata.format); + assert.strictEqual('object', typeof metadata.xmp); + assert.strictEqual(true, metadata.xmp instanceof Buffer); + + const xmpString = metadata.xmp.toString(); + assert.strictEqual(true, xmpString.includes('WebP Creator')); + assert.strictEqual(true, xmpString.includes('image/webp')); + }); + + it('withXmp XMP validation - non-string input', function () { + assert.throws( + () => sharp().withXmp(123), + /Expected non-empty string for xmp but received 123 of type number/ + ); + }); + + it('withXmp XMP validation - null input', function () { + assert.throws( + () => sharp().withXmp(null), + /Expected non-empty string for xmp but received null of type object/ + ); + }); + + it('withXmp XMP validation - empty string', function () { + assert.throws( + () => sharp().withXmp(''), + /Expected non-empty string for xmp/ + ); + }); + }); + describe('Invalid parameters', function () { it('String orientation', function () { assert.throws(function () {