Add keepXmp and withXmp for control over output XMP metadata #4416

This commit is contained in:
Thibaut Patel 2025-06-11 21:24:09 +02:00 committed by Lovell Fuller
parent df5454e7dc
commit 4e3f3792ad
10 changed files with 303 additions and 2 deletions

View File

@ -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

View File

@ -242,6 +242,57 @@ const outputWithP3 = await sharp(input)
```
## keepXmp
> keepXmp() ⇒ <code>Sharp</code>
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) ⇒ <code>Sharp</code>
Set XMP metadata in the output image.
Supported by PNG, JPEG, WebP, and TIFF output.
**Throws**:
- <code>Error</code> Invalid parameters
**Since**: 0.34.3
| Param | Type | Description |
| --- | --- | --- |
| xmp | <code>string</code> | String containing XMP metadata to be embedded in the output image. |
**Example**
```js
const xmpString = `
<?xml version="1.0"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:creator><rdf:Seq><rdf:li>John Doe</rdf:li></rdf:Seq></dc:creator>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>`;
const data = await sharp(input)
.withXmp(xmpString)
.toBuffer();
```
## keepMetadata
> keepMetadata() ⇒ <code>Sharp</code>

View File

@ -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).

View File

@ -306,6 +306,7 @@ const Sharp = function (input, options) {
withIccProfile: '',
withExif: {},
withExifMerge: true,
withXmp: '',
resolveWithObject: false,
loop: -1,
delay: [],

16
lib/index.d.ts vendored
View File

@ -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.

View File

@ -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 = `
* <?xml version="1.0"?>
* <x:xmpmeta xmlns:x="adobe:ns:meta/">
* <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
* <rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
* <dc:creator><rdf:Seq><rdf:li>John Doe</rdf:li></rdf:Seq></dc:creator>
* </rdf:Description>
* </rdf:RDF>
* </x:xmpmeta>`;
*
* 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,

View File

@ -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<void*>(static_cast<void const*>(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");

View File

@ -202,6 +202,7 @@ struct PipelineBaton {
std::string withIccProfile;
std::unordered_map<std::string, std::string> withExif;
bool withExifMerge;
std::string withXmp;
int timeoutSeconds;
std::vector<double> convKernel;
int convKernelWidth;

View File

@ -692,6 +692,8 @@ sharp(input)
k2: 'v2'
}
})
.keepXmp()
.withXmp('test')
.keepIccProfile()
.withIccProfile('filename')
.withIccProfile('filename', { attach: false });

View File

@ -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('<?xpacket begin="')), 0);
});
it('keepXmp preserves existing XMP metadata from input', async () => {
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('<?xpacket begin="')), 0);
});
it('withXmp with custom XMP replaces existing XMP', async () => {
const customXmp = '<?xml version="1.0"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:creator><rdf:Seq><rdf:li>Test Creator</rdf:li></rdf:Seq></dc:creator><dc:title><rdf:Alt><rdf:li xml:lang="x-default">Test Title</rdf:li></rdf:Alt></dc:title></rdf:Description></rdf:RDF></x:xmpmeta>';
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 = '<?xml version="1.0"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:description><rdf:Alt><rdf:li xml:lang="x-default">Added via Sharp</rdf:li></rdf:Alt></dc:description></rdf:Description></rdf:RDF></x:xmpmeta>';
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 = '<?xml version="1.0"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:subject><rdf:Bag><rdf:li>test</rdf:li><rdf:li>metadata</rdf:li></rdf:Bag></dc:subject></rdf:Description></rdf:RDF></x:xmpmeta>';
// 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 = '<?xml version="1.0"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier>persistent-test</dc:identifier></rdf:Description></rdf:RDF></x:xmpmeta>';
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 = '<?xml version="1.0"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:creator><rdf:Seq><rdf:li>WebP Creator</rdf:li></rdf:Seq></dc:creator><dc:format>image/webp</dc:format></rdf:Description></rdf:RDF></x:xmpmeta>';
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 () {