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 () {