mirror of
https://github.com/lovell/sharp.git
synced 2025-07-09 10:30:15 +02:00
Add keepXmp and withXmp for control over output XMP metadata #4416
This commit is contained in:
parent
df5454e7dc
commit
4e3f3792ad
@ -320,3 +320,6 @@ GitHub: https://github.com/qpincon
|
|||||||
|
|
||||||
Name: Hans Chen
|
Name: Hans Chen
|
||||||
GitHub: https://github.com/hans00
|
GitHub: https://github.com/hans00
|
||||||
|
|
||||||
|
Name: Thibaut Patel
|
||||||
|
GitHub: https://github.com/tpatel
|
||||||
|
@ -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
|
||||||
> keepMetadata() ⇒ <code>Sharp</code>
|
> keepMetadata() ⇒ <code>Sharp</code>
|
||||||
|
|
||||||
|
@ -31,6 +31,10 @@ Requires libvips v8.17.0
|
|||||||
[#4412](https://github.com/lovell/sharp/pull/4412)
|
[#4412](https://github.com/lovell/sharp/pull/4412)
|
||||||
[@kleisauke](https://github.com/kleisauke)
|
[@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
|
### v0.34.2 - 20th May 2025
|
||||||
|
|
||||||
* Ensure animated GIF to WebP conversion retains loop (regression in 0.34.0).
|
* Ensure animated GIF to WebP conversion retains loop (regression in 0.34.0).
|
||||||
|
@ -306,6 +306,7 @@ const Sharp = function (input, options) {
|
|||||||
withIccProfile: '',
|
withIccProfile: '',
|
||||||
withExif: {},
|
withExif: {},
|
||||||
withExifMerge: true,
|
withExifMerge: true,
|
||||||
|
withXmp: '',
|
||||||
resolveWithObject: false,
|
resolveWithObject: false,
|
||||||
loop: -1,
|
loop: -1,
|
||||||
delay: [],
|
delay: [],
|
||||||
|
14
lib/index.d.ts
vendored
14
lib/index.d.ts
vendored
@ -730,6 +730,20 @@ declare namespace sharp {
|
|||||||
*/
|
*/
|
||||||
withIccProfile(icc: string, options?: WithIccProfileOptions): 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.
|
* 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.
|
* The default behaviour, when withMetadata is not used, is to strip all metadata and convert to the device-independent sRGB colour space.
|
||||||
|
@ -312,6 +312,59 @@ function withIccProfile (icc, options) {
|
|||||||
return this;
|
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.
|
* Keep all metadata (EXIF, ICC, XMP, IPTC) from the input image in the output image.
|
||||||
*
|
*
|
||||||
@ -1576,6 +1629,8 @@ module.exports = function (Sharp) {
|
|||||||
withExifMerge,
|
withExifMerge,
|
||||||
keepIccProfile,
|
keepIccProfile,
|
||||||
withIccProfile,
|
withIccProfile,
|
||||||
|
keepXmp,
|
||||||
|
withXmp,
|
||||||
keepMetadata,
|
keepMetadata,
|
||||||
withMetadata,
|
withMetadata,
|
||||||
toFormat,
|
toFormat,
|
||||||
|
@ -876,7 +876,12 @@ class PipelineWorker : public Napi::AsyncWorker {
|
|||||||
image.set(s.first.data(), s.second.data());
|
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
|
// Number of channels used in output image
|
||||||
baton->channels = image.bands();
|
baton->channels = image.bands();
|
||||||
baton->width = image.width();
|
baton->width = image.width();
|
||||||
@ -1706,6 +1711,7 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
baton->withExifMerge = sharp::AttrAsBool(options, "withExifMerge");
|
baton->withExifMerge = sharp::AttrAsBool(options, "withExifMerge");
|
||||||
|
baton->withXmp = sharp::AttrAsStr(options, "withXmp");
|
||||||
baton->timeoutSeconds = sharp::AttrAsUint32(options, "timeoutSeconds");
|
baton->timeoutSeconds = sharp::AttrAsUint32(options, "timeoutSeconds");
|
||||||
baton->loop = sharp::AttrAsUint32(options, "loop");
|
baton->loop = sharp::AttrAsUint32(options, "loop");
|
||||||
baton->delay = sharp::AttrAsInt32Vector(options, "delay");
|
baton->delay = sharp::AttrAsInt32Vector(options, "delay");
|
||||||
|
@ -202,6 +202,7 @@ struct PipelineBaton {
|
|||||||
std::string withIccProfile;
|
std::string withIccProfile;
|
||||||
std::unordered_map<std::string, std::string> withExif;
|
std::unordered_map<std::string, std::string> withExif;
|
||||||
bool withExifMerge;
|
bool withExifMerge;
|
||||||
|
std::string withXmp;
|
||||||
int timeoutSeconds;
|
int timeoutSeconds;
|
||||||
std::vector<double> convKernel;
|
std::vector<double> convKernel;
|
||||||
int convKernelWidth;
|
int convKernelWidth;
|
||||||
|
@ -692,6 +692,8 @@ sharp(input)
|
|||||||
k2: 'v2'
|
k2: 'v2'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.keepXmp()
|
||||||
|
.withXmp('test')
|
||||||
.keepIccProfile()
|
.keepIccProfile()
|
||||||
.withIccProfile('filename')
|
.withIccProfile('filename')
|
||||||
.withIccProfile('filename', { attach: false });
|
.withIccProfile('filename', { attach: false });
|
||||||
|
@ -1100,6 +1100,170 @@ describe('Image metadata', function () {
|
|||||||
assert.strictEqual(exif2.Image.Software, 'sharp');
|
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 () {
|
describe('Invalid parameters', function () {
|
||||||
it('String orientation', function () {
|
it('String orientation', function () {
|
||||||
assert.throws(function () {
|
assert.throws(function () {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user