diff --git a/docs/api-output.md b/docs/api-output.md index 4ddc403f..435af548 100644 --- a/docs/api-output.md +++ b/docs/api-output.md @@ -119,6 +119,7 @@ sRGB colour space and strip all metadata, including the removal of any ICC profi - `options` **[Object][6]?** - `options.orientation` **[number][9]?** value between 1 and 8, used to update the EXIF `Orientation` tag. - `options.icc` **[string][2]?** filesystem path to output ICC profile, defaults to sRGB. + - `options.exif` **[Object][6]<[Object][6]>** Object keyed by IFD0, IFD1 etc. of key/value string pairs to write as EXIF data. (optional, default `{}`) ### Examples @@ -129,6 +130,19 @@ sharp('input.jpg') .then(info => { ... }); ``` +```javascript +// Set "IFD0-Copyright" in output EXIF metadata +await sharp(input) + .withMetadata({ + exif: { + IFD0: { + Copyright: 'Wernham Hogg' + } + } + }) + .toBuffer(); +``` + - Throws **[Error][4]** Invalid parameters Returns **Sharp** diff --git a/docs/changelog.md b/docs/changelog.md index d6c7f57d..76b67eb7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,6 +8,9 @@ Requires libvips v8.10.6 * Ensure all installation errors are logged with a more obvious prefix. +* Allow `withMetadata` to set and update EXIF metadata. + [#650](https://github.com/lovell/sharp/issues/650) + * Add support for OME-TIFF Sub Image File Directories (subIFD). [#2557](https://github.com/lovell/sharp/issues/2557) diff --git a/lib/constructor.js b/lib/constructor.js index 34679d6d..ab992180 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -232,6 +232,7 @@ const Sharp = function (input, options) { withMetadata: false, withMetadataOrientation: -1, withMetadataIcc: '', + withMetadataStrs: {}, resolveWithObject: false, // output format jpegQuality: 80, diff --git a/lib/output.js b/lib/output.js index cda816a3..4ea73a47 100644 --- a/lib/output.js +++ b/lib/output.js @@ -148,9 +148,22 @@ function toBuffer (options, callback) { * .toFile('output-with-metadata.jpg') * .then(info => { ... }); * + * @example + * // Set "IFD0-Copyright" in output EXIF metadata + * await sharp(input) + * .withMetadata({ + * exif: { + * IFD0: { + * Copyright: 'Wernham Hogg' + * } + * } + * }) + * .toBuffer(); + * * @param {Object} [options] * @param {number} [options.orientation] value between 1 and 8, used to update the EXIF `Orientation` tag. * @param {string} [options.icc] filesystem path to output ICC profile, defaults to sRGB. + * @param {Object} [options.exif={}] Object keyed by IFD0, IFD1 etc. of key/value string pairs to write as EXIF data. * @returns {Sharp} * @throws {Error} Invalid parameters */ @@ -171,6 +184,25 @@ function withMetadata (options) { throw is.invalidParameterError('icc', 'string filesystem path to ICC profile', options.icc); } } + if (is.defined(options.exif)) { + if (is.object(options.exif)) { + for (const [ifd, entries] of Object.entries(options.exif)) { + if (is.object(entries)) { + for (const [k, v] of Object.entries(entries)) { + if (is.string(v)) { + this.options.withMetadataStrs[`exif-${ifd.toLowerCase()}-${k}`] = v; + } else { + throw is.invalidParameterError(`exif.${ifd}.${k}`, 'string', v); + } + } + } else { + throw is.invalidParameterError(`exif.${ifd}`, 'object', entries); + } + } + } else { + throw is.invalidParameterError('exif', 'object', options.exif); + } + } } return this; } diff --git a/src/common.cc b/src/common.cc index 761f7a1e..ae25df02 100644 --- a/src/common.cc +++ b/src/common.cc @@ -36,6 +36,9 @@ namespace sharp { std::string AttrAsStr(Napi::Object obj, std::string attr) { return obj.Get(attr).As(); } + std::string AttrAsStr(Napi::Object obj, unsigned int const attr) { + return obj.Get(attr).As(); + } uint32_t AttrAsUint32(Napi::Object obj, std::string attr) { return obj.Get(attr).As().Uint32Value(); } diff --git a/src/common.h b/src/common.h index be698cbe..a5707cb8 100644 --- a/src/common.h +++ b/src/common.h @@ -95,6 +95,7 @@ namespace sharp { // Convenience methods to access the attributes of a Napi::Object bool HasAttr(Napi::Object obj, std::string attr); std::string AttrAsStr(Napi::Object obj, std::string attr); + std::string AttrAsStr(Napi::Object obj, unsigned int const attr); uint32_t AttrAsUint32(Napi::Object obj, std::string attr); int32_t AttrAsInt32(Napi::Object obj, std::string attr); int32_t AttrAsInt32(Napi::Object obj, unsigned int const attr); diff --git a/src/pipeline.cc b/src/pipeline.cc index 23b19e36..7683cc07 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -717,11 +717,17 @@ class PipelineWorker : public Napi::AsyncWorker { ->set("input_profile", "srgb") ->set("intent", VIPS_INTENT_PERCEPTUAL)); } - // Override EXIF Orientation tag if (baton->withMetadata && baton->withMetadataOrientation != -1) { image = sharp::SetExifOrientation(image, baton->withMetadataOrientation); } + // Metadata key/value pairs, e.g. EXIF + if (!baton->withMetadataStrs.empty()) { + image = image.copy(); + for (const auto& s : baton->withMetadataStrs) { + image.set(s.first.data(), s.second.data()); + } + } // Number of channels used in output image baton->channels = image.bands(); @@ -1379,6 +1385,12 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) { baton->withMetadata = sharp::AttrAsBool(options, "withMetadata"); baton->withMetadataOrientation = sharp::AttrAsUint32(options, "withMetadataOrientation"); baton->withMetadataIcc = sharp::AttrAsStr(options, "withMetadataIcc"); + Napi::Object mdStrs = options.Get("withMetadataStrs").As(); + Napi::Array mdStrKeys = mdStrs.GetPropertyNames(); + for (unsigned int i = 0; i < mdStrKeys.Length(); i++) { + std::string k = sharp::AttrAsStr(mdStrKeys, i); + baton->withMetadataStrs.insert(std::make_pair(k, sharp::AttrAsStr(mdStrs, k))); + } // Format-specific baton->jpegQuality = sharp::AttrAsUint32(options, "jpegQuality"); baton->jpegProgressive = sharp::AttrAsBool(options, "jpegProgressive"); diff --git a/src/pipeline.h b/src/pipeline.h index 34580386..db6387a2 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -168,6 +169,7 @@ struct PipelineBaton { bool withMetadata; int withMetadataOrientation; std::string withMetadataIcc; + std::unordered_map withMetadataStrs; std::unique_ptr convKernel; int convKernelWidth; int convKernelHeight; diff --git a/test/unit/metadata.js b/test/unit/metadata.js index e2695788..8474a56a 100644 --- a/test/unit/metadata.js +++ b/test/unit/metadata.js @@ -599,6 +599,30 @@ describe('Image metadata', function () { }); }); + it('Add EXIF metadata to JPEG', async () => { + const data = await sharp({ + create: { + width: 8, + height: 8, + channels: 3, + background: 'red' + } + }) + .jpeg() + .withMetadata({ + exif: { + IFD0: { Software: 'sharp' }, + IFD2: { ExposureTime: '0.2' } + } + }) + .toBuffer(); + + const { exif } = await sharp(data).metadata(); + const parsedExif = exifReader(exif); + assert.strictEqual(parsedExif.image.Software, 'sharp'); + assert.strictEqual(parsedExif.exif.ExposureTime, 0.2); + }); + it('chromaSubsampling 4:4:4:4 CMYK JPEG', function () { return sharp(fixtures.inputJpgWithCmykProfile) .metadata() @@ -717,5 +741,20 @@ describe('Image metadata', function () { sharp().withMetadata({ icc: true }); }); }); + it('Non object exif', function () { + assert.throws(function () { + sharp().withMetadata({ exif: false }); + }); + }); + it('Non string value in object exif', function () { + assert.throws(function () { + sharp().withMetadata({ exif: { ifd0: false } }); + }); + }); + it('Non string value in nested object exif', function () { + assert.throws(function () { + sharp().withMetadata({ exif: { ifd0: { fail: false } } }); + }); + }); }); });