├── .eslintrc.cjs ├── .gitignore ├── .npmignore ├── LICENSE.txt ├── README.md ├── TODO.md ├── __tests__ ├── exif.test.js ├── images │ ├── IMG_1820.heic │ ├── IMG_1820.jpg │ ├── IPTC-PhotometadataRef-Std2021.1.jpg │ ├── Murph_mild_haze.jpg │ ├── copper.jpg │ ├── fake.cjs │ ├── fake.js │ ├── fake.json │ ├── fake.mjs │ ├── needs-a-thumbnail.jpg │ ├── nemo.jpeg │ ├── nullisland.jpeg │ ├── strip.jpg │ ├── thumbnail.jpg │ └── tickle.txt └── setConfigPathTest │ └── exiftool.config ├── jest.config.mjs ├── package-lock.json ├── package.json └── src ├── index.js └── which.js /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es2021: true, 4 | node: true, 5 | }, 6 | extends: [ 7 | 'airbnb-base', 8 | ], 9 | parserOptions: { 10 | ecmaVersion: 12, 11 | sourceType: 'module', 12 | }, 13 | rules: { 14 | semi: ['error', 'never'], 15 | 'no-console': 'off', 16 | 'no-underscore-dangle': 'off', 17 | 'import/prefer-default-export': 'off', 18 | 'max-len': 'off', 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # x.509 keys for testing 2 | *.pem 3 | *.crt 4 | *.cert 5 | *.csr 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | tmp 16 | .npmrc 17 | *.config.bk 18 | *.config.test 19 | __tests__/images/gps 20 | __tests__/images/copy* 21 | __tests__/images/*_original 22 | src/exiftool.config* 23 | 24 | # Diagnostic reports (https://nodejs.org/api/report.html) 25 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 26 | 27 | # Runtime data 28 | pids 29 | *.pid 30 | *.seed 31 | *.pid.lock 32 | 33 | # Directory for instrumented libs generated by jscoverage/JSCover 34 | lib-cov 35 | 36 | # Coverage directory used by tools like istanbul 37 | coverage 38 | *.lcov 39 | 40 | # nyc test coverage 41 | .nyc_output 42 | 43 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 44 | .grunt 45 | 46 | # Bower dependency directory (https://bower.io/) 47 | bower_components 48 | 49 | # node-waf configuration 50 | .lock-wscript 51 | 52 | # Compiled binary addons (https://nodejs.org/api/addons.html) 53 | build/Release 54 | 55 | # Dependency directories 56 | node_modules/ 57 | jspm_packages/ 58 | 59 | # TypeScript v1 declaration files 60 | typings/ 61 | 62 | # TypeScript cache 63 | *.tsbuildinfo 64 | 65 | # Optional npm cache directory 66 | .npm 67 | 68 | # Optional eslint cache 69 | .eslintcache 70 | 71 | # Microbundle cache 72 | .rpt2_cache/ 73 | .rts2_cache_cjs/ 74 | .rts2_cache_es/ 75 | .rts2_cache_umd/ 76 | 77 | # Optional REPL history 78 | .node_repl_history 79 | 80 | # Output of 'npm pack' 81 | *.tgz 82 | 83 | # Yarn Integrity file 84 | .yarn-integrity 85 | 86 | # dotenv environment variables file 87 | .env 88 | .env.test 89 | 90 | # parcel-bundler cache (https://parceljs.org/) 91 | .cache 92 | 93 | # Next.js build output 94 | .next 95 | 96 | # Nuxt.js build / generate output 97 | .nuxt 98 | dist 99 | 100 | # Gatsby files 101 | .cache/ 102 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 103 | # https://nextjs.org/blog/next-9-1#public-directory-support 104 | # public 105 | 106 | # vuepress build output 107 | .vuepress/dist 108 | 109 | # Serverless directories 110 | .serverless/ 111 | 112 | # FuseBox cache 113 | .fusebox/ 114 | 115 | # DynamoDB Local files 116 | .dynamodb/ 117 | 118 | # TernJS port file 119 | .tern-port 120 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | TODO.md 3 | tmp 4 | src/exiftool.config* 5 | *.config.bk 6 | *.config.test 7 | __tests__/images/gps 8 | __tests__/images/copy* 9 | __tests__/images/*_original 10 | 11 | *.config.bk 12 | *.config.test 13 | .*.swp 14 | ._* 15 | .DS_Store 16 | .git 17 | .npmrc 18 | .lock-wscript 19 | *.env 20 | npm-debug.log 21 | node_modules 22 | package-lock.json 23 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2022 Matthew Duffy 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Extract Image Metadata with Exiftool 2 | 3 | This package for Node.js provides an object-oriented wrapper around the phenomenal utility, [exiftool](https://exiftool.org), created by [Phil Harvey](https://exiftool.org/index.html#donate). This package requires the ```exiftool``` perl library to already be installed. Installation instructions can be found [here](https://exiftool.org/install.html). This package is compatible with POSIX systems; it will not work on a Windows machine. This package will not run in a browser. 4 | 5 | ## Using Exiftool 6 | This package attempts to abstract the various uses of exiftool into a small collection of distinct methods that help to reduce the difficulty of composing complex metadata processing incantations. The Exiftool class instantiates with a reasonable set of default options to produce explicitly labeled, yet compact, metadata output in JSON format. The included options are easily modified if necessary, and even more customized exiftool incantations can be created and saved as [shortcuts](https://exiftool.org/TagNames/Shortcuts.html) in an [exiftool.config](https://exiftool.org/config.html) file. 7 | 8 | ```bash 9 | npm install --save @mattduffy/exiftool 10 | ``` 11 | 12 | ```javascript 13 | import { Exiftool } from '@mattduffy/exiftool' 14 | let exiftool = new Exiftool() 15 | ``` 16 | 17 | The Exiftool class constructor does most of the initial setup. A call to the ```init()``` method is currently necessary to complete setup because it makes some asynchronous calls to determine the location of exiftool, whether the exiftool.config file is present - creating one if not, and composing the exiftool command string from the default options. The ```init()``` method takes a string parameter which is the file system path to an image file or a directory of images. This is an **Async/Await** method. 18 | ```javascript 19 | exiftool = await exiftool.init( '/www/site/images/myNicePhoto.jpg' ) 20 | 21 | // or in one line... 22 | let exiftool = await new Exiftool().init( '/www/site/images/myNicePhoto.jpg' ) 23 | ``` 24 | It is also possible to pass an array of strings to the ```init()``` method if you have more than one image. 25 | ```javascript 26 | let images = ['/www/site/images/one.jpg', '/www/site/images/two.jpg', '/www/site/images/three.jpg'] 27 | let exiftool = await new Exiftool().init(images) 28 | ``` 29 | 30 | At this point, Exiftool is ready to extract metadata from the image ```myNicePhoto.jpg```. Use the ```getMetadata()``` to extract the metadata. This is an **Async/Await** method. 31 | 32 | There are a few options to choose what metadata is extracted from the file: 33 | - using default options, including a pre-configured shortcut 34 | - override the default shortcut name with a different one (already added to the exiftool.config file) 35 | - adding additional [Exiftool Tags](https://exiftool.org/TagNames/index.html) to extract beyond those included in the default shortcut 36 | 37 | ```javascript 38 | // Using just the default options. 39 | let metadata1 = await exiftool.getMetadata() 40 | 41 | // Changing the image path, if you want, for some reason. 42 | let metadata2 = await exiftool.getMetadata( '/path/to/a/different/image.jpg', '', '' ) 43 | 44 | // Using all the default options, but calling a different shortcut 45 | // previously saved to the exiftool.config file. 46 | let metadata3 = await exiftool.getMetadata( '', 'ADifferentSavedShortcut', '' ) 47 | 48 | // Adding Tags to command to be extracted, in additon to those 49 | // specified in the shortcut. For example, extracting LensInfo, 50 | // FocalLength, ImageWidth and ImageHeight values (if present). 51 | let metadata4 = await exiftool.getMetadata( '', '', 'EXIF:LensInfo', 'EXIF:FocalLength', 'File:ImageWidth', 'File:ImageHeight' ) 52 | 53 | // All three parameters can be used at once if desired. 54 | let metadata5 = await exiftool.getMetadata( '/path/to/a/different/image.jpg', 'ADifferentSavedShortcut', 'EXIF:LensInfo', 'EXIF:FocalLength', 'File:ImageWidth', 'File:ImageHeight' ) 55 | ``` 56 | 57 | The simplest use of Exiftool looks like this: 58 | ```javascript 59 | import { Exiftool } from '@mattduffy/exiftool' 60 | let exiftool = await new Exiftool().init( 'images/copper.jpg' ) 61 | let metadata = await exiftool.getMetadata() 62 | console.log( metatdata ) 63 | // [ 64 | // { 65 | // SourceFile: 'images/copper.jpg', 66 | // 'File:FileName': 'copper.jpg', 67 | // 'EXIF:ImageDescription': 'Copper curtain fixture', 68 | // 'IPTC:ObjectName': 'Tiny copper curtain rod', 69 | // 'IPTC:Caption-Abstract': 'Copper curtain fixture', 70 | // 'IPTC:Keywords': 'copper curtain fixture', 71 | // 'Composite:GPSPosition': `48 deg 8' 49.20" N, 17 deg 5' 52.80" E` 72 | // }, 73 | // { 74 | // exiftool_command: '/usr/local/bin/exiftool -config /home/node_packages/exiftool/exiftool.config -json -BasicShortcut -groupNames -s3 -quiet --ext TXT --ext JS --ext JSON --ext MJS --ext CJS --ext MD --ext HTML images/copper.jpg' 75 | // }, 76 | // 1 77 | // ] 78 | ``` 79 | The ```exiftool_command``` property is the command composed from all the default options, using the pre-configured BasicShortcut saved in the exiftool.config file. 80 | 81 | The last element in the metadata array is the count of files that exiftool inspected and returned data for. 82 | 83 | #### Command Not Found! 84 | 85 | This node.js package can only function if [exiftool](https://exiftool.org/install.html) is installed. This node.js package DOES NOT install the necessary, underlying ```exiftool``` executable. If ```exiftool``` is not installed, or is not available in the system path, it will throw an error and interrupt execution. 86 | 87 | ```javascript 88 | let exiftool = await new Exiftool().init( 'images/copper.jpg' ) 89 | Error: ATTENTION!!! exiftool IS NOT INSTALLED. You can get exiftool here: https://exiftool.org/install.html 90 | at Exiftool.init (file:///www/exiftool/src/index.js:83:13) 91 | at async REPL17:1:38 { 92 | [cause]: Error: Exiftool not found? 93 | at Exiftool.which (file:///www/exiftool/src/index.js:498:13) 94 | at async Exiftool.init (file:///www/exiftool/src/index.js:73:28) 95 | at async REPL17:1:38 96 | at async node:repl:646:29 { 97 | [cause]: Error: Command failed: which exitfool 98 | ``` 99 | 100 | #### Extracting Binary Tag Data 101 | There are several tags that store binary data, such as image thumbnails, color profile, image digests, etc.. The default state for exiftool is to not extract binary data from tags. If you would like to extract the binary data, use the ```enableBinaryTagOutput()``` method before calling the ```getMetadata()``` method. 102 | 103 | ```javascript 104 | let exiftool = await new Exiftool().init( 'images/copper.jpg' ) 105 | exiftool.enableBinaryTagOutput(true) 106 | let metadata = await exiftool.getMetadata() 107 | let thumbnail = metadata[0]['EXIF:ThumbnailImage'] 108 | console.log(thumbnail) 109 | // 'base64:/9j/4AAQSkZJRgABAgEASABIAAD/4QKkaHR.........' 110 | ``` 111 | 112 | #### Embedded Thumbnail Images 113 | There are several tags that may store tiny thumbnails or previews of the containing image file. ```Exiftool``` provides two simple methods for accessing thumbnail data. The ```getThumbnails()``` method will return a JSON object containing each version of thumbnail data (Base64 encoded) embedded in the file. This method is an **Async/Await** method. 114 | ```javascript 115 | let exiftool = await new Exiftool().init( 'images/copper.jgp' ) 116 | const thumbnails = await exiftool.getThumbnails() 117 | console.log(thumbnails) 118 | // [ 119 | // { 120 | // SourceFile: '/www/images/copper.jpg', 121 | // 'EXIF:ThumbnailImage': 'base64:/9j/wAA...' 122 | // }, 123 | // { 124 | // exiftool_command: '/usr/local/bin/exiftool -config "/../exiftool/src/exiftool.config" -json -Preview:all -groupNames -s3 -quiet --ext cjs --ext css --ext html --ext js --ext json --ext md --ext mjs --ext txt -binary "/www/images/copper.jpg"' 125 | // }, 126 | // { format: 'json' }, 127 | // ] 128 | ``` 129 | 130 | Setting a thumbnail is easy, with the ```setThumbnail()``` method. The first parameter is required, a path to the thumbnail file to be embedded. The default tag name used to store the thumbnail data is ```EXIF:ThumbnailImage```. This is an **Async/Await** method. 131 | ```javascript 132 | let exiftool = await new Exiftool().init( 'images/copper.jgp' ) 133 | const result = await exiftool.setThumbnails('images/new-thumbnail.jpg') 134 | console.log(result) 135 | // { 136 | // stdout: '', 137 | // stderr: '', 138 | // exiftool_command: '/usr/local/bin/exiftool -config "/../exiftool/src/exiftool.config" -json "-EXIF:ThumbnailImage<=/www/images/new-thumbnail.jpg" "/www/images/copper.jpg"', 139 | // success: true 140 | // } 141 | ``` 142 | 143 | #### Metadata Output Format 144 | The default output format when issuing metadata queries is JSON. You can change the output format to XML by calling the ```setOutputFormat()``` method before calling the ```getMetadata()``` method. 145 | 146 | ```javascript 147 | let exiftool = await new Exiftool().init( 'images/copper.jpg' ) 148 | exiftool.setOutputFormat('xml') 149 | let xml = await exiftool.getMetadata() 150 | console.log(xml) 151 | // [ 152 | // " 153 | // 154 | // 158 | // 3.3 MB 159 | // JPEG 160 | // ... 161 | // YCbCr4:2:0 (2 2) 162 | // 163 | // ", 164 | // { raw: "..." }, 165 | // { format: 'xml' }, 166 | // { exiftool_command: '/usr/local/bin/exiftool -config exiftool.config -json -xmp:all -groupNames -s3 -quiet --ext cjs --ext css --ext html --ext js --ext json --ext md --ext mjs --ext txt images/copper.jpg'}, 167 | // 1, 168 | // ] 169 | ``` 170 | 171 | #### Location Coordinate Output Formatting 172 | The default output format used by ```exiftool``` to report location coordinates looks like ```54 deg 59' 22.80"```. The coordinates output format can be changed using `printf` style syntax strings. To change the location coordinate output format, use the ```setGPSCoordinatesOutputFormat()``` method before calling the ```getMetadata()``` method. ```ExifTool``` provides a simple alias ```gps``` to set the output to typical GPS style ddd.nnnnnn notation (%.6f printf syntax, larger number will provide higer precision). See the [exiftool -coordFormat](https://exiftool.org/exiftool_pod.html#c-FMT--coordFormat) documentation for more details on controlling coordinate output formats. 173 | 174 | ```javascript 175 | let exiftool = await new Exiftool().init( 'images/copper.jpg' ) 176 | let defaultLocationFormat = await exiftool.getMetadata('', null, 'EXIF:GPSLongitude', 'EXIF:GPSLongitudeRef') 177 | console.log(defaultLocationFormat[0]['EXIF:GPSLongitude'], defaultLocationFormat[0]['EXIF:GPSLongitudeRef']) 178 | // 122 deg 15' 16.51" West 179 | exiftool.setGPSCoordinatesOutputFormat('gps') 180 | // or exiftool.setGPSCoordinatesOutputFormat('+gps') for signed lat/lon values in Composite:GPS* tags 181 | let myLocationFormat = await exiftool.getMetadata('', null, 'EXIF:GPSLongitude', 'EXIF:GPSLongitudeRef') 182 | console.log(myLocationFormat[0]['EXIF:GPSLongitude'], myLocationFormat[0]['EXIF:GPSLongitudeRef']) 183 | // 122.254586 West 184 | ``` 185 | 186 | #### Raw XMP Packet Data 187 | To extract the full Adobe XMP packet, if it exists within an image file, you can use the ```getXmpPacket()``` method. This method will extract only the xmp metadata. The metadata will be a serialized string version of the raw XMP:RDF packet object. This is an **Async/Await** method. 188 | 189 | ```javascript 190 | let exiftool = await new Exiftool().init( 'images/copper.jpg' ) 191 | let xmpPacket = await exiftool.getXmpPacket() 192 | console.log(xmpPacket) 193 | // { 194 | // exiftool_command: '/usr/local/bin/exiftool -config /app/src/exiftool.config -xmp -b /www/images/copper.jpg', 195 | // xmp: '' 198 | // } 199 | ``` 200 | 201 | #### XMP Structured Tags 202 | XMP tags can contain complex, structured content. ```exiftool``` is able to extract this [structured content](https://exiftool.org/struct.html), or flatten it into a single value. The default state for exiftool is to flatten the tag values. If you would like to extract the complex structured data, use the ```enableXMPStructTagOutput()``` method before calling the ```getMetadata()``` method. See the [exiftool -struct](https://exiftool.org/exiftool_pod.html#struct---struct) documentation for more details on how to access nested / structured fields in XMP tags. 203 | 204 | ```javascript 205 | let exiftool = await new Exiftool().init( 'images/copper.jpg' ) 206 | exiftool.enableXMPStructTagOutput(true) 207 | let metadata = await exiftool.getMetadata() 208 | ``` 209 | 210 | #### MWG Composite Tags 211 | The Metadata Working Group has created a recommendation for how to read and write to tags which contain values repeated in more than one tag group. ```exiftool``` provides the ability to keep these overlapping tag values synchronized with the [MWG module](https://exiftool.org/TagNames/MWG.html). Use the ```useMWG()``` method to cause ```exiftool``` to follow the MWG 2.0 recommendations. The overlapping tags will be reduced to their 2.0 recommendation and reported assigned to the ```Composite:*``` tag group. 212 | 213 | ```javascript 214 | let exiftool = await new Exiftool().init( 'images/IPTC-PhotometadataRef-Std2021.1.jpg' ) 215 | exiftool.useMWG(true) 216 | let metadata = await exiftool.getMetadata('', '', '-MWG:*') 217 | console.log(metadata[0]) 218 | // [ 219 | // { 220 | // SourceFile: 'images/IPTC-PhotometadataRef-Std2021.1.jpg', 221 | // 'Composite:City': 'City (Location shown1) (ref2021.1)', 222 | // 'Composite:Country': 'CountryName (Location shown1) (ref2021.1)', 223 | // 'Composite:Copyright': 'Copyright (Notice) 2021.1 IPTC - www.iptc.org (ref2021.1)', 224 | // 'Composite:Description': 'The description aka caption (ref2021.1)', 225 | // 'Composite:Keywords': [ 'Keyword1ref2021.1', 'Keyword2ref2021.1', 'Keyword3ref2021.1' ] 226 | // }, 227 | // { 228 | // exiftool_command: '/usr/local/bin/exiftool -config /home/node_packages/exiftool/exiftool.config -json -BasicShortcut -groupNames -s3 -quiet --ext TXT --ext JS --ext JSON --ext MJS --ext CJS --ext MD --ext HTML images/IPTC-PhotometadataRef-Std2021.1.jpg' 229 | // }, 230 | // 1 231 | // ] 232 | ``` 233 | 234 | #### Excluding Files by File Type 235 | Because ```exiftool``` is such a well designed utility, it naturally handles metadata queries to directories containing images just as easily as to a specific image file. It will automatically recurse through a directory and process any image file types that it knows about. Exiftool is designed with this in mind, by setting a default list of file types to exclude, including TXT, JS, CJS, MJS, JSON, MD, HTML, and CSS. This behavior can be altered by modifying the list of extensions to exclude with the ```setExtensionsToExclude()``` method. 236 | 237 | ```javascript 238 | import { Exiftool } from '@mattduffy/exiftool' 239 | let exiftool = new Exiftool() 240 | let extensionsArray = img.getExtensionsToExclude() 241 | extensionsArray.push( 'ESLINT' ) 242 | img.setExtensionsToExclude( extensionsArray ) 243 | exiftool = await exiftool.init( 'images/' ) 244 | let metadata = await exiftool.getMetadata() 245 | console.log( metatdata ) 246 | [ 247 | { 248 | SourceFile: 'images/IMG_1820.heic', 249 | 'File:FileName': 'IMG_1820.heic', 250 | 'Composite:GPSPosition': `48 deg 8' 49.20" N, 17 deg 5' 52.80" E` 251 | }, 252 | { 253 | SourceFile: 'images/copper.jpg', 254 | 'File:FileName': 'copper.jpg', 255 | 'EXIF:ImageDescription': 'Copper curtain fixture', 256 | 'IPTC:ObjectName': 'Tiny copper curtain rod', 257 | 'IPTC:Caption-Abstract': 'Copper curtain fixture', 258 | 'IPTC:Keywords': 'copper curtain fixture', 259 | 'Composite:GPSPosition': `48 deg 8' 49.20" N, 17 deg 5' 52.80" E` 260 | }, 261 | { 262 | SourceFile: 'images/IMG_1820.jpg', 263 | 'File:FileName': 'IMG_1820.jpg', 264 | 'Composite:GPSPosition': `48 deg 8' 49.20" N, 17 deg 5' 52.80" E` 265 | 266 | }, 267 | { 268 | exiftool_command: '/usr/local/bin/exiftool -config /home/node_package_development/exiftool/exiftool.config -json -coordFormat "%.6f" -BasicShortcut -groupNames -s3 -quiet --ext TXT --ext JS --ext JSON --ext MJS --ext CJS --ext MD --ext HTML --ext ESLINT images/' 269 | }, 270 | 3 271 | ] 272 | ``` 273 | 274 | ### The exiftool.config File 275 | This file is not required to be present to process metadata by the original ```exiftool```, but it can help a lot with complex queries, so this Exiftool package uses it. During the ```init()``` setup, a check is performed to see if the file is present in the root of the package directory. If no file is found, a very basic file is created, populated with a simple shortcut called ```BasicShortcut```. The path to this file can be overridden to use a different file. 276 | 277 | ```javascript 278 | let exiftool = await new Exiftool().init( '/path/to/image.jpg' ) 279 | let oldConfigPath = exiftool.getConfigPath() 280 | console.log( oldConfigPath ) 281 | { 282 | value: '/path/to/the/exiftool/exiftool.config', 283 | error: null 284 | } 285 | ``` 286 | ```javascript 287 | let newConfigFile = '/path/to/new/exiftool.config' 288 | let result = await exiftool.setConfigPath( newConfigFile ) 289 | let metadata = await exiftool.getMetadata() 290 | ``` 291 | 292 | ### Shortcuts 293 | The original ```exiftool``` provides a very convenient way to save arbitrarily complex metadata queries in the form of **shortcuts** saved in an ```exiftool.config``` file. New shortcuts can be added to the ```exiftool.config``` managed by the package. If a different ```exiftool.config``` file is used, do not try to save new shortcuts to that file with this method. To add a new shortcut, use ```addShortcut()```. This is an **Async/Await** method. 294 | 295 | ```javascript 296 | let exiftool = await new Exiftool().init( '/path/to/image.jpg' ) 297 | // Check to see if a shortcut with this name is already 298 | // present in the package provided exiftool.config file 299 | if (!await exiftool.hasShortcut( 'MyCoolShortcut' )) { 300 | // Shortcut was not found, save the shortcut definition to the exiftool.config file 301 | let result = await exiftool.addShortcut( "MyCoolShortcut => ['EXIF:LensInfo', 'EXIF:FocalLength', 'File:ImageWidth', 'File:ImageHeight']" ) 302 | console.log( result ) 303 | } 304 | ``` 305 | To change the default shortcut (BasicShortcut) to something else, that has already been added to the ```exiftool.config``` file, use the ```setShortcut()``` method. 306 | 307 | ```javascript 308 | let exiftool = await new Exiftool().init( '/path/to/image.jpg' ) 309 | let result = exiftool.setShortcut( 'MyCoolShortcut' ) 310 | let metadata = await exiftool.getMetadata() 311 | 312 | // Alternatively, pass the shortcut name as a parameter in the getMetadata() method 313 | let metadata = await exiftool.getMetadata( '', 'MyCoolShortcut', '' ) 314 | ``` 315 | 316 | To remove a shortcut from the package provided ```exiftool.config``` file use the ```removeShortcut()``` method. This is an **Async/Await** method. 317 | 318 | ```javascript 319 | let exiftool = await new Exiftool().init( '/path/to/image.jpg' ) 320 | // Check to see if a shortcut with this name is already 321 | // present in the package provided exiftool.config file 322 | if (await exiftool.hasShortcut( 'MyCoolShortcut' )) { 323 | // Shortcut was found, now remove it 324 | let result = await exiftool.removeShortcut( 'MyCoolShortcut' ) 325 | console.log( result ) 326 | } 327 | ``` 328 | The ```exiftool.config``` file generated by ```Exiftool``` includes a few useful shortcuts: 329 | - BasicShortcut 330 | - Location 331 | - StripGPS 332 | 333 | 334 | Exiftool creates a backup of the ```exiftool.config``` file each time it is modified by the ```addShortcut()``` or ```removeShortcut()``` methods. 335 | 336 | ### Writing Metadata to a Tag 337 | ```exiftool``` makes it easy to write new metadata values to any of the hundreds of tags it supports by specifying the tag name and the new value to write. Any number of tags can be written to in one command. A full discussion is beyond the scope of this documentation, but information on the types of tag values (strings, lists, numbers, binary data, etc.) can be found [here](https://exiftool.org/TagNames/index.html). Exiftool provides the ```writeMetadataToTag()``` method to support this functionality. This method works on a single image file at a time. It takes either a string, or an array of strings, formated according to these [rules](https://exiftool.org/exiftool_pod.html#WRITING-EXAMPLES). 338 | 339 | The general format to write a new value to a tag is: ```-TAG=VALUE``` where TAG is the tag name, ```=``` means write the new ```VALUE```. For tags that store list values, you can add an item to the list ```-TAG+=VALUE```. The ```+=``` is like ```Array.push()```. Likewise, ```-TAG-=VALUE``` is like ```Array.pop()```. 340 | 341 | This is an **Async/Await** method. 342 | 343 | ```javascript 344 | let exiftool = await new Exiftool().init( '/path/to/image.jpg' ) 345 | let tagToWrite = '-IPTC:Headline="Wow, Great Photo!"' 346 | let result1 = await exiftool.writeMetadataToTag( tagToWrite ) 347 | console.log(result1) 348 | //{ 349 | // value: true, 350 | // error: null, 351 | // command: '/usr/local/bin/exiftool -IPTC:Headline="Wow, Great Photo!" /path/to/image.jpg', 352 | // stdout: '1 image files updated' 353 | //} 354 | ``` 355 | Multiple tags can be written to at once by passing an array to ```writeMetadataToTag()```. 356 | 357 | ```javascript 358 | let exiftool = await new Exiftool().init( '/path/to/image.jpg' ) 359 | let tagArray = ['-IPTC:Contact="Photo McCameraguy"', '-IPTC:Keywords+=News', '-IPTC:Keywords+=Action'] 360 | let result2 = await exiftool.writeMetadataToTag( tagArray ) 361 | console.log(result2) 362 | //{ 363 | // value: true, 364 | // error: null, 365 | // command: '/usr/local/bin/exiftool -IPTC:Contact="Photo McCameraguy" -IPTC:Keywords+=News -IPTC:Keywords+=Action /path/to/image.jpg', 366 | // stdout: '1 image files updated' 367 | //} 368 | ``` 369 | 370 | #### Setting a Location 371 | There are many tags that can contain location-related metadata, from GPS coordinates, to locality names. Setting a location is complicated by the fact that there is more than one tag group capable of holding these valuse. IPTC, EXIF, and XMP can all store some amount of overlapping location data. The Metadata Working Group provides a way to keep some of these values in sync across tag groups, but doesn't include location coordinates. To help keep location data accurate and in-sync, ```Exiftool``` provides the ```setLocation()``` method. It takes an object literal parameter with latitude/longitude coordinates and locality names if desired. This is an **Async/Await** method. 372 | 373 | ```javascript 374 | let exiftool = await new Exiftool().init('/path/to/image.jpg') 375 | const coordinates = { 376 | latitude: 40.748193, 377 | longitude: -73.985062, 378 | city: 'New York City', // optional 379 | state: 'New York', // optional 380 | country: 'United States', // optional 381 | countryCode: 'USA', // optional 382 | location: 'Empire State Building', // optional 383 | } 384 | const result = await exiftool.setLocation(coordinates) 385 | ``` 386 | 387 | ### Clearing Metadata From a Tag 388 | Tags can be cleared of their metadata value. This is essentially the same as writing an empty string to the tag. This is slighlty different that stripping the tag entirely from the image. Exiftool provides the ```clearMetadataFromTag()``` method to clear tag values. This leaves the empty tag in the image file so it can be written to again if necessary. Like the ```writeMetadataToTag()``` method, this one also takes either a string or an array of strings as a parameter. This is an **Async/Await** method. 389 | 390 | ```javascript 391 | let exiftool = await new Exiftool().init('/path/to/image.jpg') 392 | let tagToClear = '-IPTC:Contact^=' 393 | let result = await exiftool.clearMetadataFromTag(tagToClear) 394 | console.log(result) 395 | ``` 396 | 397 | ### Stripping Metadata From an Image 398 | It is possible to strip all of the existing metadata from an image with this Exiftool package. The default behavior of the original ```exiftool``` utility, when writing metadata to an image is to make a backup copy of the original image file. The new file will keep the original file name, while the backup will have **_original** appended to the name. Exiftool maintains this default behavior. 399 | 400 | ```javascript 401 | let exiftool = await new Exiftool().init( '/path/to/image.jpg' ) 402 | let result = await exiftool.stripMetadata() 403 | /* 404 | This will result in two files: 405 | - /path/to/image.jpg (contains no metadata in the file) 406 | - /path/to/image.jpg_original (contains all the original metadata) 407 | */ 408 | ``` 409 | 410 | If you would like to change the default exiftool behavior, to overwrite the original image file, call the ```setOverwriteOriginal()``` method after the ```init()``` method. 411 | 412 | ```javascript 413 | let exiftool = await new Exiftool().init('myPhoto.jpg') 414 | exiftool.setOverwriteOriginal(true) 415 | let result await exiftool.stripMetadata() 416 | /* 417 | This will result in one file: 418 | - /path/to/myPhoto.jpg (contains no metadata in the file) 419 | */ 420 | ``` 421 | 422 | If GPS location data is the only metadata that needs to be stripped, the ```stripLocation()``` method can be used. This method updates the images in place. It can be called on either a directory of images or a single image. This is an **Async/Await** method. 423 | ```javascript 424 | let exiftool = await new Exiftool().init('/path/to/images') 425 | await exiftool.stripLocation() 426 | // { 427 | // stdout: ' 1 directories scanned\n 4 image files updated\n', 428 | // stderr: '', 429 | // exiftool_command: '/usr/local/bin/exiftool -gps:all= /path/to/images/' 430 | // } 431 | ``` 432 | 433 | ### Making Metadata Queries Directly 434 | It may be more convenient sometimes to issue a metadata query to ```exiftool``` directly rather than compose it through the class configured default options and methods. Running complex, one-off queries recursively across a directory of images might be a good use for issuing a command composed outside of Exiftool. This is an **Async/Await** method. 435 | 436 | ```javascript 437 | let exiftool = new Exiftool() 438 | let result = await exiftool.raw('/usr/local/bin/exiftool b -jpgfromraw -w %d%f_%ue.jpg -execute -binary -previewimage -w %d%f_%ue.jpg -execute -tagsfromfile @ -srcfile %d%f_%ue.jpg -common_args --ext jpg /path/to/image/directory') 439 | console.log(result) 440 | ``` 441 | 442 | ### Setting File Extension for Exiftool to ignnore 443 | Exiftool maintains a list of file extensions to tell ```exiftool``` to ignore when the target of the metadata query is a directory rather than a file. This list of file extensions can be updated as necessary. The ```setExtensionsToExclude()``` method may take two array parameters. The first paramater is an array of file extensions to add to the exclude list. The second paramater is an array of file extensions to remove from the current exclude list. 444 | 445 | ```javascript 446 | let exiftool = new Exiftool() 447 | console.log(exiftool.getExtensionsToExclude()) 448 | // [ 'cjs', 'css', 'html', 'js', 'json', 'md', 'mjs', 'txt' ] 449 | const extensionsToAdd = ['scss','yaml'] 450 | const extensionsToRemove = ['txt'] 451 | exiftool.setExtensionsToExclude(extensionsToAdd, extensionsToRemove) 452 | console.log(exiftool.getExtensionsToExclude()) 453 | // [ 'cjs', 'css', 'html', 'js', 'json', 'md', 'mjs', 'scss', 'yaml' ] 454 | ``` 455 | ```javascript 456 | // Just adding file extensions 457 | exiftool.setExtensionsToExclude(extensionsToAdd) 458 | 459 | // Just removing file extensions 460 | exiftool.setExtensionsToExclude(null, extensionsToRemove) 461 | ``` 462 | 463 | ### Exiftool Version and Location 464 | Exiftool is [updated](https://exiftool.org/history.html) very frequently, so it might be useful to know which version is installed and being used by this package. If a TAG is present in the image metadata, but not being returned in the query, the installed version of Exiftool might not know about it and need to be updated. The install location and version of Exiftool are both queryable. These are **Async/Await** methods. 465 | 466 | ```javascript 467 | let exiftool = new Exiftool() 468 | console.log(await exiftool.which()) 469 | // /usr/local/bin/exiftool 470 | console.log(await exiftool.version()) 471 | // 12.46 472 | ``` 473 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | 2 | - [x] constructor: create a class constructor method to initialize exiftool 3 | - [x] init: create an options object to set exiftool output behavior 4 | - [x] - add a jest test case for instance creation 5 | - [x] which: create a class method to verify exiftool is avaiable 6 | - [x] - add a jest test case to verify exiftool is available 7 | - [x] get/setExtensionsToExclude: create class methods to get/set extention type array 8 | - [x] - add a jest test case to verify get/set methods 9 | - [x] getPath: create a class method to return the configured path to image / image directory 10 | - [x] - add a jest test case to get the value of instance \_path property 11 | - [x] hasExiftoolConfigFile: create a class method to check if exiftool.config file exists 12 | - [x] - add a jest test case to find present/missing config file 13 | - [x] createExiftoolConfigFile: create a class method to create exiftool.config file if missing 14 | - [x] - add a jest test case to verify creation of new config file 15 | - [x] - add a jest teardown to remove newly created copies of the exiftool.config file 16 | - [x] get/setConfigPath: create a class method to point to a different exiftool.config file 17 | - [x] - add a jest test case to verify changing exiftool.config file 18 | - [x] hasShortcut: create a class method to check if a shortcut exists 19 | - [x] - add a jest test case to check if a shortcut exists 20 | - [x] addShortcut: create a class method to add a shortcut 21 | - [x] - add a jest test case to add a shortcut 22 | - [x] removeShortcut: create a class method to remove a shortcut 23 | - [x] - add a jest test case to remove a shortcut 24 | - [x] getMetadata: create a class method to extract metadata using custom shortcut 25 | - [x] - add a jest test case to extract metadata using a custom shortcut 26 | - [x] getMetadata: create a class method to extract all metadata 27 | - [x] - add a jest test case to extract all metadata 28 | - [x] getMetadata: create a class method to extract arbitrary metadata 29 | - [x] - add a jest test case to extract arbitrary metadata 30 | - [x] - add a jest test case to prevent passing -all= tag to getMetadata method 31 | - [x] stripMetadata: create a class method to strip all metadata from an image 32 | - [x] - add a jest test case to strip all metadata from an image 33 | - [x] writeToTag: create a class method to write metadata to an metadata tag 34 | - [x] - add a jest test case to write metadata to a designated metadata tag 35 | - [x] clearTag: create a class method to clear the value of a designated tag 36 | - [x] - add a jest test case to clear metadata from a designated tag 37 | - [x] raw: create a class method to send a fully composed metadata query to exiftool, ignoring defaults 38 | - [x] - add a jest test case to send a fully composed metadata query to exiftool 39 | - [x] version: create a class method to report the version of exiftool installed 40 | - [x] - modify the setPath method so it accepts relative paths to an image 41 | - [x] - modify jest test case that detected relative paths as an error, to allow 42 | - [x] stripLocation: create a class method to just clear GPS metadata 43 | - [x] nemo: create a class method to add GPS metadata for point nemo 44 | - [x] add some usefull shortcuts to the exiftool.config file 45 | - [ ] add functionality to list versions backedup exiftool.config file 46 | - [ ] add functionality to restore a previous version of exiftool.config file 47 | - [ ] create a cli invocable version of exiftool 48 | 49 | 50 | -------------------------------------------------------------------------------- /__tests__/exif.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module @mattduffy/exiftool 3 | * @author Matthew Duffy 4 | * @file __tests__/exif.test.js A Jest test suite testing the methods of the Exiftool class. 5 | */ 6 | import path from 'node:path' 7 | import { fileURLToPath } from 'node:url' 8 | import { 9 | copyFile, 10 | mkdir, 11 | rm, 12 | stat, 13 | } from 'node:fs/promises' 14 | import Debug from 'debug' 15 | /* eslint-disable import/extensions */ 16 | import { Exiftool } from '../src/index.js' 17 | import { path as executable } from '../src/which.js' 18 | 19 | const __filename = fileURLToPath(import.meta.url) 20 | const __dirname = path.dirname(__filename) 21 | Debug.log = console.log.bind(console) 22 | const debug = Debug('exiftool:Test') 23 | const error = debug.extend('ERROR') 24 | debug(`exif path: ${executable}`) 25 | debug(Exiftool) 26 | 27 | // Set the items to be used for all the tests here as constants. 28 | 29 | const testsDir = __dirname 30 | const imageDir = `${__dirname}/images` 31 | const image1 = `${imageDir}/copper.jpg` 32 | const image2 = `${imageDir}/IMG_1820.jpg` 33 | const image3 = `${imageDir}/IMG_1820.heic` 34 | const image4 = `${imageDir}/strip.jpg` 35 | const image5 = `${imageDir}/nemo.jpeg` 36 | const image6 = `${imageDir}/nullisland.jpeg` 37 | // const image7 = `${imageDir}/IPTC-PhotometadataRef-Std2021.1.jpg` 38 | const image8 = `${imageDir}/Murph_mild_haze.jpg` 39 | const image9 = `${imageDir}/needs-a-thumbnail.jpg` 40 | const thumbnail = `${imageDir}/thumbnail.jpg` 41 | const spacey = 'SNAPCHAT MEMORIES' 42 | const spaceyPath = `${__dirname}/${spacey}/Murph_mild_haze.jpg` 43 | const RealShortcut = 'BasicShortcut' 44 | const FakeShortcut = 'FakeShortcut' 45 | const NewShortcut = 'MattsNewCut' 46 | const MattsNewCut = "MattsNewCut => ['exif:createdate', 'file:FileName']" 47 | 48 | debug(`testsDir: ${testsDir}`) 49 | debug(`imageDir: ${imageDir}`) 50 | debug(`image1: ${image1}`) 51 | debug(`image2: ${image2}`) 52 | debug(`image3: ${image3}`) 53 | debug(`image4: ${image4}`) 54 | 55 | /* eslint-disable no-undef */ 56 | beforeAll(async () => { 57 | const log = debug.extend('before-all') 58 | const err = error.extend('before-all') 59 | try { 60 | const spaceyDirPath = path.resolve(__dirname, spacey) 61 | log(`Creating test path with spaces: ${spaceyDirPath}`) 62 | await mkdir(spaceyDirPath, { recursive: true }) 63 | log(`${spaceyDirPath} exists? ${(await stat(spaceyDirPath)).isDirectory()}`) 64 | const spaceyDirPathFile = path.resolve(spaceyDirPath, 'Murph_mild_haze.jpg') 65 | log(spaceyDirPathFile) 66 | await copyFile(image8, spaceyDirPathFile) 67 | const spaceyConfigPath = path.resolve(__dirname, 'setConfigPathTest', spacey) 68 | log(spaceyConfigPath) 69 | await mkdir(spaceyConfigPath, { recursive: true }) 70 | const src = path.resolve(__dirname, 'setConfigPathTest', 'exiftool.config') 71 | const dest = path.resolve(spaceyConfigPath, 'exiftool.config') 72 | log(`copy ${src} -> ${dest}`) 73 | await copyFile(src, dest) 74 | } catch (e) { 75 | err(e) 76 | } 77 | }) 78 | 79 | afterAll(async () => { 80 | const log = debug.extend('after-all') 81 | const err = error.extend('after-all') 82 | const dir = __dirname.split('/') 83 | const file = `${dir.slice(0, dir.length - 1).join('/')}/exiftool.config` 84 | log(dir) 85 | try { 86 | await rm(`${file}.bk`) 87 | } catch (e) { 88 | err(e) 89 | } 90 | try { 91 | await rm(`${file}.test`) 92 | } catch (e) { 93 | err(e) 94 | } 95 | try { 96 | await rm(path.resolve(__dirname, 'setConfigPathTest', spacey), { recursive: true, force: true }) 97 | } catch (e) { 98 | err(e) 99 | } 100 | try { 101 | await rm(path.resolve(__dirname, spacey), { recursive: true, force: true }) 102 | } catch (e) { 103 | err(e) 104 | } 105 | }) 106 | 107 | describe('Exiftool metadata extractor', () => { 108 | test('it should be an instance of Exiftool', () => { 109 | expect(new Exiftool()).toBeInstanceOf(Exiftool) 110 | }) 111 | 112 | test('setExtensionsToExclude: update array of file type extensions to exclude', async () => { 113 | const log = debug.extend('test-01-setExtensionsToExclude') 114 | let img = new Exiftool() 115 | const extensionsArray = img.getExtensionsToExclude() 116 | extensionsArray.push('CONFIG') 117 | log(extensionsArray) 118 | img.setExtensionsToExclude(extensionsArray) 119 | img = await img.init(image1) 120 | const excludes = img._opts.excludeTypes 121 | expect(excludes).toMatch(/CONFIG/) 122 | }) 123 | 124 | test('init: should fail without a path arguement', async () => { 125 | const log = debug.extend('test-02-init-should-fail') 126 | let img = new Exiftool() 127 | log('no init arguments passed') 128 | expect(img = await img.init()).toBeFalsy() 129 | }) 130 | 131 | test('init: with a path should return a configured exiftool', async () => { 132 | const log = debug.extend('test-03-init-pass') 133 | expect.assertions(2) 134 | let img = new Exiftool() 135 | img = await img.init(image1) 136 | log(`init argument: ${image1}`) 137 | expect(img._isDirectory).toBeDefined() 138 | expect(img).toHaveProperty('_fileStats') 139 | }) 140 | 141 | test('which: exiftool is accessible in the path', async () => { 142 | const log = debug.extend('test-04-which') 143 | const img = new Exiftool() 144 | const exif = await img.which() 145 | log(exif) 146 | expect(exif).toMatch(/exiftool/) 147 | }) 148 | 149 | test('get/setConfigPath: change the file system path to the exiftool.config file', async () => { 150 | const log = debug.extend('test-05-get/setConfigPath') 151 | expect.assertions(4) 152 | const img = new Exiftool() 153 | // setConfigPathTest/exiftool.config 154 | const newConfigFile = `${__dirname}/setConfigPathTest/exiftool.config` 155 | log(newConfigFile) 156 | const oldConfigFile = img.getConfigPath() 157 | expect(oldConfigFile.value).toMatch(/exiftool\/src\/exiftool.config$/) 158 | 159 | const result = await img.setConfigPath(newConfigFile) 160 | expect(result.value).toBeTruthy() 161 | expect(img._command).toMatch(/setConfigPathTest/) 162 | 163 | // test a bad file path 164 | // setConfigPathTest/bad/exiftool.config 165 | const badConfigFile = `${__dirname}/setConfigPathTest/bad/exiftool.config` 166 | const badResult = await img.setConfigPath(badConfigFile) 167 | expect(badResult.e.code).toMatch(/ENOENT/) 168 | }) 169 | 170 | test('setPath: is the path to file or directory', async () => { 171 | const log = debug.extend('test-06-setPath') 172 | expect.assertions(5) 173 | const img = new Exiftool() 174 | // missing path value 175 | const result1 = await img.setPath() 176 | expect(result1.value).toBeNull() 177 | expect(result1.error).toBe('A path to image or directory is required.') 178 | const result2 = await img.setPath(image1) 179 | expect(result2.value).toBeTruthy() 180 | expect(result2.error).toBeNull() 181 | 182 | /* Relative paths are now acceptable */ 183 | // test with a relative path to generate an error 184 | try { 185 | const img1 = new Exiftool() 186 | const newPath = '__tests__/images/copper.jpg' 187 | const result3 = await img1.setPath(newPath) 188 | log(await img1.getPath()) 189 | log(result3) 190 | expect(result3.error).toBeNull() 191 | } catch (e) { 192 | expect(e).toBeInstanceOf(Error) 193 | } 194 | }) 195 | 196 | test('hasExiftoolConfigFile: check if exiftool.config file is present', async () => { 197 | const log = debug.extend('test-07-hasExiftoolConfigFile') 198 | expect.assertions(2) 199 | const img1 = new Exiftool() 200 | log('hasExiftoolConfigFile check - good check') 201 | expect(await img1.hasExiftoolConfigFile()).toBeTruthy() 202 | 203 | const img2 = new Exiftool() 204 | img2._exiftool_config = `${img2._cwd}/exiftool.config.missing` 205 | log('hasExiftoolConfigFile check - bad check') 206 | expect(await img2.hasExiftoolConfigFile()).toBeFalsy() 207 | }) 208 | 209 | test('createExiftoolConfigFile: can create new exiftool.config file', async () => { 210 | const log = debug.extend('test-08-createExiftoolConfigFile') 211 | expect.assertions(2) 212 | let img = new Exiftool() 213 | img = await img.init(testsDir) 214 | img._exiftool_config = `${img._cwd}/exiftool.config.test` 215 | const result = await img.createExiftoolConfigFile() 216 | log(img.getConfigPath()) 217 | expect(result.value).toBeTruthy() 218 | expect(img.hasExiftoolConfigFile()).toBeTruthy() 219 | }) 220 | 221 | test('hasShortcut: check exiftool.config for a shortcut', async () => { 222 | const log = debug.extend('test-09-hasShortcut') 223 | expect.assertions(2) 224 | const img = new Exiftool() 225 | const result1 = await img.hasShortcut(RealShortcut) 226 | log(result1) 227 | expect(result1).toBeTruthy() 228 | const result2 = await img.hasShortcut(FakeShortcut) 229 | expect(result2).toBeFalsy() 230 | }) 231 | 232 | test('addShortcut: add a new shortcut to the exiftool.config file', async () => { 233 | const log = debug.extend('test-10-addShortcut') 234 | expect.assertions(4) 235 | const img1 = new Exiftool() 236 | const result1 = await img1.addShortcut(MattsNewCut) 237 | log(result1) 238 | expect(result1.value).toBeTruthy() 239 | expect(result1.error).toBeNull() 240 | 241 | // check if new shortcut exists and can be returned 242 | let img2 = new Exiftool() 243 | img2 = await img2.init(image1) 244 | const result2 = await img2.hasShortcut(NewShortcut) 245 | expect(result2).toBeTruthy() 246 | 247 | // get metadata using new shortcut 248 | img2.setShortcut(NewShortcut) 249 | log(img2._command) 250 | const metadata = await img2.getMetadata() 251 | log(metadata) 252 | expect(metadata).not.toBeNull() 253 | }) 254 | 255 | test('removeShortcut: remove a given shortcut from the exiftool.config file', async () => { 256 | const log = debug.extend('test-11-removeShortcut') 257 | const img1 = new Exiftool() 258 | const result1 = await img1.removeShortcut(NewShortcut) 259 | log(result1) 260 | expect(result1.value).toBeTruthy() 261 | }) 262 | 263 | test('getMetadata: specify tag list as an optional parameter', async () => { 264 | const log = debug.extend('test-12-getMetadata-specify-tags') 265 | expect.assertions(3) 266 | // test adding additional tags to the command 267 | let img1 = new Exiftool() 268 | // init with the copper.jpg image1 269 | img1 = await img1.init(image1) 270 | const result1 = await img1.getMetadata('', '', ['file:FileSize', 'file:DateTimeOriginal', 'file:Model']) 271 | const count = parseInt(result1.slice(-1)[0], 10) 272 | log(count) 273 | expect(count).toBe(1) 274 | expect(result1[0]).toHaveProperty('File:FileSize') 275 | expect(result1[0]).toHaveProperty('EXIF:ImageDescription') 276 | }) 277 | 278 | test('getMetadata: specify new file name and tag list as an optional parameter', async () => { 279 | const log = debug.extend('test-13-getMetadata-new-file') 280 | // test changing the file from one set in init() 281 | expect.assertions(2) 282 | let img2 = new Exiftool() 283 | // init with copper.jpg image1 284 | img2 = await img2.init(image1) 285 | // replace image1 with IMG_1820.jpg 286 | const result2 = await img2.getMetadata(image2, '', ['file:FileSize', 'file:DateTimeOriginal', 'file:ImageSize']) 287 | log(result2[0]) 288 | expect(result2[0]).toHaveProperty('File:FileSize') 289 | expect(result2[0]).toHaveProperty('Composite:GPSPosition') 290 | }) 291 | 292 | test('getMetadata: specify new shortcut name and tag list as an optional parameter', async () => { 293 | const log = debug.extend('test-14-getMetadata-new-shortcut') 294 | // test passing a new shortcut name 295 | let img3 = new Exiftool() 296 | // image3 is IMG_1820.heic 297 | img3 = await img3.init(image3) 298 | const result3 = await img3.getMetadata('', NewShortcut, ['file:FileSize', 'file:ImageSize']) 299 | log(result3[0]['file:FileSize']) 300 | expect(result3[0]).toHaveProperty('SourceFile') 301 | }) 302 | 303 | test('getMetadata: catch the forbidden -all= data stripping tag', async () => { 304 | const log = debug.extend('test-15-getMetadata-catch-forbidden-tag') 305 | // test catching the -all= stripping tag in get request 306 | let img4 = new Exiftool() 307 | // init with the copper.jpg image1 308 | img4 = await img4.init(image1) 309 | try { 310 | await img4.getMetadata('', '', '-all= ') 311 | } catch (e) { 312 | log(e) 313 | expect(e).toBeInstanceOf(Error) 314 | } 315 | }) 316 | 317 | test('stripMetadata: strip all the metadata out of a file and keep a backup of the original file', async () => { 318 | const log = debug.extend('test-16-stripMetadata') 319 | log() 320 | // test stripping all metadata from an image file 321 | expect.assertions(2) 322 | let img1 = new Exiftool() 323 | // init with strip.jpg image 4 324 | img1 = await img1.init(image4) 325 | const result = await img1.stripMetadata() 326 | expect(result.value).toBeTruthy() 327 | expect(result.original).toMatch(/_original/) 328 | }) 329 | 330 | test('writeMetadataToTag: write new metadata to one of more designate tags', async () => { 331 | const log = debug.extend('test-17-writeMetadataToTag') 332 | // test writing new metadata to a designated tag 333 | let img1 = new Exiftool() 334 | // init with copper.jpg image1 335 | img1 = await img1.init(image1) 336 | const data1 = '-IPTC:Headline="Wow, Great Photo!" -IPTC:Keywords+=TEST' 337 | log(data1) 338 | const result1 = await img1.writeMetadataToTag(data1) 339 | expect.assertions(3) 340 | expect(result1).toHaveProperty('value', true) 341 | expect(result1.stdout.trim()).toMatch(/1 image files updated/) 342 | 343 | // test writing new metadata to more than one designated tag 344 | let img2 = new Exiftool() 345 | // init with strip.jpg image 4 346 | img2 = await img2.init(image4) 347 | try { 348 | await img2.writeMetadataToTag() 349 | } catch (e) { 350 | expect(e).toBeInstanceOf(Error) 351 | } 352 | }) 353 | 354 | test('clearMetadataFromTag: clear metadata from one or more designated tags', async () => { 355 | const log = debug.extend('test-18-clearMetadataFromTag') 356 | // test clearing metadata values from a designated tag 357 | let img1 = new Exiftool() 358 | // init with strip.jpg image4 359 | img1 = await img1.init(image4) 360 | const data1 = '-IPTC:Headline="Wow, Great Photo!" -IPTC:Contact=TEST' 361 | log(data1) 362 | await img1.writeMetadataToTag(data1) 363 | const tag = ['-IPTC:Headline^=', '-IPTC:Contact^='] 364 | const result1 = await img1.clearMetadataFromTag(tag) 365 | expect(result1.stdout.trim()).toMatch(/1 image files updated/) 366 | }) 367 | 368 | test('raw: send a fully composed exiftool command, bypassing instance config defualts', async () => { 369 | const log = debug.extend('test-19-raw') 370 | // test sending a raw exiftool command 371 | const img1 = new Exiftool() 372 | const command = `${executable} -G -json -EXIF:ImageDescription -IPTC:ObjectName -IPTC:Keywords ${image1}` 373 | log(command) 374 | const result1 = await img1.raw(command) 375 | expect(result1[0]).toHaveProperty('IPTC:ObjectName') 376 | }) 377 | 378 | test('nemo: set the gps location to point nemo', async () => { 379 | const log = debug.extend('test-20-nemo') 380 | // test set location to point nemo 381 | const img1 = await new Exiftool().init(image5) 382 | img1.setGPSCoordinatesOutputFormat('gps') 383 | await img1.nemo() 384 | const result1 = await img1.getMetadata('', null, '-GPS:all') 385 | log(result1[0]['EXIF:GPSLatitude']) 386 | expect.assertions(2) 387 | expect(result1[0]).toHaveProperty('EXIF:GPSLatitude') 388 | expect(Number.parseFloat(result1[0]['EXIF:GPSLatitude'])).toEqual(22.319469) 389 | }) 390 | 391 | test('null island: set the gps location to null island', async () => { 392 | const log = debug.extend('test-21-null-island') 393 | // test set location to null island 394 | const img1 = await new Exiftool().init(image6) 395 | img1.setGPSCoordinatesOutputFormat('gps') 396 | await img1.nullIsland() 397 | const result1 = await img1.getMetadata('', null, '-GPS:all') 398 | expect.assertions(2) 399 | expect(result1[0]).toHaveProperty('EXIF:GPSLatitude') 400 | log(result1[0]['EXIF:GPSLatitude']) 401 | expect(Number.parseFloat(result1[0]['EXIF:GPSLatitude'])).toEqual(0) 402 | }) 403 | 404 | test('set output format to xml', async () => { 405 | const log = debug.extend('test-22-output-to-xml') 406 | log() 407 | const img8 = await new Exiftool().init(image8) 408 | const shouldBeTrue = img8.setOutputFormat('xml') 409 | expect.assertions(3) 410 | expect(shouldBeTrue).toBeTruthy() 411 | const result1 = await img8.getMetadata('', null, '-File:all') 412 | expect(result1[1].raw.slice(0, 5)).toMatch(' { 417 | const log = debug.extend('test-23-xmp-packet') 418 | const img8 = await new Exiftool().init(image8) 419 | const packet = await img8.getXmpPacket() 420 | log(packet) 421 | const pattern = /^<\?xpacket .*\?>.*/ 422 | expect(packet.xmp).toMatch(pattern) 423 | // expect(packet.xmp).toMatch(/^<\?xpacket\s?(?begin=.*)?\s?(?id=.*)?(.*)?>.*(?<\?xpacket.*>)/) 424 | }) 425 | 426 | test('exiftool command not found', async () => { 427 | const log = debug.extend('test-24-command-not-found') 428 | log() 429 | const exiftoolNotFound = new Exiftool(null, true) 430 | try { 431 | await exiftoolNotFound.init(image1) 432 | } catch (e) { 433 | expect(e.message).toEqual(expect.stringMatching(/attention/i)) 434 | } 435 | const exiftool = await new Exiftool().init(image1) 436 | expect(exiftool._executable).toEqual(expect.stringMatching(/.+\/exiftool?/)) 437 | }) 438 | 439 | test('handle spaces in image path', async () => { 440 | const log = debug.extend('test-25-spaces-in-image-path') 441 | try { 442 | const exiftool = await new Exiftool().init(spaceyPath) 443 | const metadata = await exiftool.getMetadata('', 'Preview:all') 444 | expect(metadata).toHaveLength(4) 445 | } catch (e) { 446 | log(e) 447 | } 448 | }) 449 | 450 | test('handle spaces in config file path', async () => { 451 | const log = debug.extend('test-26-spaces-in-config-file-path') 452 | const exiftool = await new Exiftool().init(spaceyPath) 453 | const newConfigFile = `${__dirname}/setConfigPathTest/SNAPCHAT MEMORIES/exiftool.config` 454 | log(newConfigFile) 455 | const result = await exiftool.setConfigPath(newConfigFile) 456 | log(result) 457 | expect.assertions(2) 458 | expect(result.value).toBeTruthy() 459 | expect(exiftool._command).toMatch(/SNAPCHAT/) 460 | }) 461 | 462 | test('use getThumbnails() method to extract preview image data', async () => { 463 | const log = debug.extend('test-27-get-thumbnails') 464 | const err = error.extend('test-27-get-thumbnails') 465 | let exiftool 466 | let thumbs 467 | try { 468 | exiftool = await new Exiftool().init(image8) 469 | thumbs = await exiftool.getThumbnails() 470 | log(thumbs[0]['EXIF:ThumbnailImage']) 471 | } catch (e) { 472 | err(e) 473 | } 474 | expect.assertions(1) 475 | expect(thumbs.length).toBeGreaterThanOrEqual(3) 476 | }) 477 | 478 | test('use setThumbnail() method to embed preview image', async () => { 479 | const log = debug.extend('test-28-set-thumbnail') 480 | const err = error.extend('test-28-set-thumbnail') 481 | const exiftool = await new Exiftool().init(image9) 482 | let result 483 | log(`needs-a-thumbnail.jpg: ${image9}`) 484 | log(`thumbnail: ${thumbnail}`) 485 | try { 486 | result = await exiftool.setThumbnail(thumbnail) 487 | } catch (e) { 488 | err(e) 489 | } 490 | expect.assertions(1) 491 | expect(result.success).toBeTruthy() 492 | }) 493 | }) 494 | -------------------------------------------------------------------------------- /__tests__/images/IMG_1820.heic: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/252af3b5d01d5818dadef84b7242c5dff0a77b21/__tests__/images/IMG_1820.heic -------------------------------------------------------------------------------- /__tests__/images/IMG_1820.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/252af3b5d01d5818dadef84b7242c5dff0a77b21/__tests__/images/IMG_1820.jpg -------------------------------------------------------------------------------- /__tests__/images/IPTC-PhotometadataRef-Std2021.1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/252af3b5d01d5818dadef84b7242c5dff0a77b21/__tests__/images/IPTC-PhotometadataRef-Std2021.1.jpg -------------------------------------------------------------------------------- /__tests__/images/Murph_mild_haze.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/252af3b5d01d5818dadef84b7242c5dff0a77b21/__tests__/images/Murph_mild_haze.jpg -------------------------------------------------------------------------------- /__tests__/images/copper.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/252af3b5d01d5818dadef84b7242c5dff0a77b21/__tests__/images/copper.jpg -------------------------------------------------------------------------------- /__tests__/images/fake.cjs: -------------------------------------------------------------------------------- 1 | console.log('fake CJS file.') 2 | -------------------------------------------------------------------------------- /__tests__/images/fake.js: -------------------------------------------------------------------------------- 1 | console.log('fake JS file.') 2 | -------------------------------------------------------------------------------- /__tests__/images/fake.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mattduffy/exiftool", 3 | "version": "1.0.0", 4 | "description": "A simple object oriented wrapper for the exiftool image metadata utility.", 5 | "main": "index.js", 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "import": "index.js" 10 | }, 11 | "package.json": "./package.json", 12 | "./tests": "./tests/*.js" 13 | }, 14 | "scripts": { 15 | "test": "jest" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/mattduffy/exiftool.git" 20 | }, 21 | "author": "mattduffy@gmail.com", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/mattduffy/exiftool/issues" 25 | }, 26 | "homepage": "https://github.com/mattduffy/exiftool#readme", 27 | "devDependencies": { 28 | "debug": "^4.3.4", 29 | "eslint": "^8.12.0", 30 | "jest": "29.1.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /__tests__/images/fake.mjs: -------------------------------------------------------------------------------- 1 | console.log('fake MJS file.') 2 | -------------------------------------------------------------------------------- /__tests__/images/needs-a-thumbnail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/252af3b5d01d5818dadef84b7242c5dff0a77b21/__tests__/images/needs-a-thumbnail.jpg -------------------------------------------------------------------------------- /__tests__/images/nemo.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/252af3b5d01d5818dadef84b7242c5dff0a77b21/__tests__/images/nemo.jpeg -------------------------------------------------------------------------------- /__tests__/images/nullisland.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/252af3b5d01d5818dadef84b7242c5dff0a77b21/__tests__/images/nullisland.jpeg -------------------------------------------------------------------------------- /__tests__/images/strip.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/252af3b5d01d5818dadef84b7242c5dff0a77b21/__tests__/images/strip.jpg -------------------------------------------------------------------------------- /__tests__/images/thumbnail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/252af3b5d01d5818dadef84b7242c5dff0a77b21/__tests__/images/thumbnail.jpg -------------------------------------------------------------------------------- /__tests__/images/tickle.txt: -------------------------------------------------------------------------------- 1 | tickle 2 | -------------------------------------------------------------------------------- /__tests__/setConfigPathTest/exiftool.config: -------------------------------------------------------------------------------- 1 | %Image::ExifTool::UserDefined::Shortcuts = ( 2 | BasicShortcut => ['EXIF:LensInfo', 'EXIF:FocalLength', 'File:ImageWidth', 'File:ImageHeight'], 3 | ); 4 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "/tmp/jest_rs", 15 | 16 | // Automatically clear mock calls, instances, contexts and results before every test 17 | // clearMocks: false, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | collectCoverage: true, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | // collectCoverageFrom: undefined, 24 | 25 | // The directory where Jest should output its coverage files 26 | // coverageDirectory: undefined, 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | // coveragePathIgnorePatterns: [ 30 | // "/node_modules/" 31 | // ], 32 | 33 | // Indicates which provider should be used to instrument code for coverage 34 | coverageProvider: "v8", 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | // coverageReporters: [ 38 | // "json", 39 | // "text", 40 | // "lcov", 41 | // "clover" 42 | // ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // The default configuration for fake timers 54 | // fakeTimers: { 55 | // "enableGlobally": false 56 | // }, 57 | 58 | // Force coverage collection from ignored files using an array of glob patterns 59 | // forceCoverageMatch: [], 60 | 61 | // A path to a module which exports an async function that is triggered once before all test suites 62 | // globalSetup: undefined, 63 | 64 | // A path to a module which exports an async function that is triggered once after all test suites 65 | // globalTeardown: undefined, 66 | 67 | // A set of global variables that need to be available in all test environments 68 | // globals: {}, 69 | 70 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 71 | // maxWorkers: "50%", 72 | 73 | // An array of directory names to be searched recursively up from the requiring module's location 74 | // moduleDirectories: [ 75 | // "node_modules" 76 | // ], 77 | 78 | // An array of file extensions your modules use 79 | // moduleFileExtensions: [ 80 | // "js", 81 | // "mjs", 82 | // "cjs", 83 | // "jsx", 84 | // "ts", 85 | // "tsx", 86 | // "json", 87 | // "node" 88 | // ], 89 | 90 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 91 | // moduleNameMapper: {}, 92 | 93 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 94 | // modulePathIgnorePatterns: [], 95 | 96 | // Activates notifications for test results 97 | // notify: false, 98 | 99 | // An enum that specifies notification mode. Requires { notify: true } 100 | // notifyMode: "failure-change", 101 | 102 | // A preset that is used as a base for Jest's configuration 103 | // preset: undefined, 104 | 105 | // Run tests from one or more projects 106 | // projects: undefined, 107 | 108 | // Use this configuration option to add custom reporters to Jest 109 | // reporters: undefined, 110 | 111 | // Automatically reset mock state before every test 112 | // resetMocks: false, 113 | 114 | // Reset the module registry before running each individual test 115 | // resetModules: false, 116 | 117 | // A path to a custom resolver 118 | // resolver: undefined, 119 | 120 | // Automatically restore mock state and implementation before every test 121 | // restoreMocks: false, 122 | 123 | // The root directory that Jest should scan for tests and modules within 124 | // rootDir: undefined, 125 | 126 | // A list of paths to directories that Jest should use to search for files in 127 | // roots: [ 128 | // "__tests__" 129 | // ], 130 | 131 | // Allows you to use a custom runner instead of Jest's default test runner 132 | // runner: "jest-runner", 133 | 134 | // The paths to modules that run some code to configure or set up the testing environment before each test 135 | // setupFiles: [], 136 | 137 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 138 | // setupFilesAfterEnv: [], 139 | 140 | // The number of seconds after which a test is considered as slow and reported as such in the results. 141 | // slowTestThreshold: 5, 142 | 143 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 144 | // snapshotSerializers: [], 145 | 146 | // The test environment that will be used for testing 147 | testEnvironment: "jest-environment-node", 148 | 149 | // Options that will be passed to the testEnvironment 150 | // testEnvironmentOptions: {}, 151 | 152 | // Adds a location field to test results 153 | // testLocationInResults: false, 154 | 155 | // The glob patterns Jest uses to detect test files 156 | testMatch: [ 157 | //"**/__tests__/**/*.[jt]s?(x)", 158 | "**/?(*.)+(spec|test).[tj]s?(x)" 159 | ], 160 | 161 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 162 | // testPathIgnorePatterns: [ 163 | // "/node_modules/" 164 | // ], 165 | 166 | // The regexp pattern or array of patterns that Jest uses to detect test files 167 | // testRegex: [], 168 | 169 | // This option allows the use of a custom results processor 170 | // testResultsProcessor: undefined, 171 | 172 | // This option allows use of a custom test runner 173 | // testRunner: "jest-circus/runner", 174 | 175 | // A map from regular expressions to paths to transformers 176 | // transform: undefined, 177 | transform: { 178 | //"^.+\\.[t|j]sx?$": "babel-jest" 179 | }, 180 | 181 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 182 | // transformIgnorePatterns: [ 183 | // "/node_modules/", 184 | // "\\.pnp\\.[^\\/]+$" 185 | // ], 186 | 187 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 188 | // unmockedModulePathPatterns: undefined, 189 | 190 | // Indicates whether each individual test should be reported during the run 191 | // verbose: undefined, 192 | 193 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 194 | // watchPathIgnorePatterns: [], 195 | 196 | // Whether to use watchman for file crawling 197 | // watchman: true, 198 | }; 199 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mattduffy/exiftool", 3 | "version": "1.14.1", 4 | "description": "A simple object oriented wrapper for the exiftool image metadata utility.", 5 | "author": "mattduffy@gmail.com", 6 | "license": "ISC", 7 | "main": "index.js", 8 | "type": "module", 9 | "homepage": "https://github.com/mattduffy/exiftool#readme", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/mattduffy/exiftool.git" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/mattduffy/exiftool/issues" 16 | }, 17 | "exports": { 18 | ".": "./src/index.js", 19 | "./which.js": "./src/which.js", 20 | "./package.json": "./package.json" 21 | }, 22 | "scripts": { 23 | "test": "DEBUG=exiftool:* node --experimental-vm-modules node_modules/jest/bin/jest.js" 24 | }, 25 | "devDependencies": { 26 | "@jest/globals": "29.2.0", 27 | "eslint": "8.26.0", 28 | "eslint-config-airbnb-base": "15.0.0", 29 | "eslint-plugin-import": "2.26.0", 30 | "jest": "29.1.2" 31 | }, 32 | "dependencies": { 33 | "debug": "4.3.4", 34 | "fast-xml-parser": "4.3.6" 35 | }, 36 | "keywords": [ 37 | "exiftool", 38 | "EXIF", 39 | "GPS", 40 | "IPTC", 41 | "XMP", 42 | "metadata", 43 | "image meta information", 44 | "jpg", 45 | "jpeg", 46 | "png", 47 | "gif", 48 | "geotag" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module @mattduffy/exiftool 3 | * @author Matthew Duffy 4 | * @file src/index.js The Exiftool class definition file. 5 | */ 6 | 7 | import path from 'node:path' 8 | import { fileURLToPath } from 'node:url' 9 | import { stat } from 'node:fs/promises' 10 | import { promisify } from 'node:util' 11 | import { exec, spawn } from 'node:child_process' 12 | import Debug from 'debug' 13 | import * as fxp from 'fast-xml-parser' 14 | 15 | const __filename = fileURLToPath(import.meta.url) 16 | const __dirname = path.dirname(__filename) 17 | const cmd = promisify(exec) 18 | const _spawn = promisify(spawn) 19 | Debug.log = console.log.bind(console) 20 | const debug = Debug('exiftool') 21 | const error = debug.extend('ERROR') 22 | 23 | /** 24 | * A class wrapping the exiftool metadata tool. 25 | * @summary A class wrapping the exiftool image metadata extraction tool. 26 | * @class Exiftool 27 | * @author Matthew Duffy 28 | */ 29 | export class Exiftool { 30 | /** 31 | * Create an instance of the exiftool wrapper. 32 | * @param { string } imagePath - String value of file path to an image file or directory of images. 33 | * @param { Boolean } [test] - Set to true to test outcome of exiftool command not found. 34 | */ 35 | constructor(imagePath, test) { 36 | const log = debug.extend('constructor') 37 | log('constructor method entered') 38 | this._test = test ?? false 39 | this._imgDir = imagePath ?? null 40 | this._path = imagePath ?? null 41 | this._isDirectory = null 42 | this._fileStats = null 43 | this._cwd = __dirname 44 | this._exiftool_config = `"${this._cwd}/exiftool.config"` 45 | this._extensionsToExclude = ['txt', 'js', 'json', 'mjs', 'cjs', 'md', 'html', 'css'] 46 | this._executable = null 47 | this._version = null 48 | this._opts = {} 49 | this._opts.exiftool_config = `-config ${this._exiftool_config}` 50 | this._opts.outputFormat = '-json' 51 | this._opts.tagList = null 52 | this._opts.shortcut = '-BasicShortcut' 53 | this._opts.includeTagFamily = '-groupNames' 54 | this._opts.compactFormat = '-s3' 55 | this._opts.quiet = '-quiet' 56 | this._opts.excludeTypes = '' 57 | this._opts.binaryFormat = '' 58 | this._opts.gpsFormat = '' 59 | this._opts.structFormat = '' 60 | this._opts.useMWG = '' 61 | this._opts.overwrite_original = '' 62 | this._command = null 63 | this.orderExcludeTypesArray() 64 | } 65 | 66 | /** 67 | * Initializes some asynchronus properties. 68 | * @summary Initializes some asynchronus class properties not done in the constructor. 69 | * @author Matthew Duffy 70 | * @async 71 | * @param { String } imagePath - A file system path to set for exiftool to process. 72 | * @return { (Exiftool|Boolean) } Returns fully initialized instance or false. 73 | */ 74 | async init(imagePath) { 75 | const log = debug.extend('init') 76 | const err = error.extend('init') 77 | log('init method entered') 78 | try { 79 | if (this._executable === null) { 80 | this._executable = await this.which() 81 | this._version = await this.version() 82 | } 83 | log('setting the command string') 84 | this.setCommand() 85 | } catch (e) { 86 | err('could not find exiftool command') 87 | // err(e) 88 | throw new Error('ATTENTION!!! exiftool IS NOT INSTALLED. You can get exiftool at https://exiftool.org/install.html', { cause: e }) 89 | } 90 | if ((imagePath === '' || typeof imagePath === 'undefined') && this._path === null) { 91 | err('Param: path - was undefined.') 92 | err(`Instance property: path - ${this._path}`) 93 | return false 94 | } 95 | try { 96 | await this.setPath(imagePath) 97 | } catch (e) { 98 | err(e) 99 | throw e 100 | } 101 | try { 102 | log('checking if config file exists.') 103 | if (await this.hasExiftoolConfigFile()) { 104 | log('exiftool.config file exists') 105 | } else { 106 | log('missing exiftool.config file') 107 | log('attempting to create basic exiftool.config file') 108 | const result = this.createExiftoolConfigFile() 109 | if (!result.value && result.error) { 110 | err('failed to create new exiftool.config file') 111 | throw new Error(result.error) 112 | } 113 | log('new exiftool.config file created') 114 | } 115 | } catch (e) { 116 | err('could not create exiftool.config file') 117 | err(e) 118 | } 119 | return this 120 | } 121 | 122 | /** 123 | * Set ExifTool to overwrite the original image file when writing new tag data. 124 | * @summary Set ExifTool to overwrite the original image file when writing new tag data. 125 | * @author Matthew Duffy 126 | * @param { Boolean } enabled - True/False value to enable/disable overwriting the original image file. 127 | * @return { undefined } 128 | */ 129 | setOverwriteOriginal(enabled) { 130 | const log = debug.extend('setOverwriteOriginal') 131 | if (enabled) { 132 | log('setting -overwrite_original option') 133 | this._opts.overwrite_original = '-overwrite_original' 134 | } else { 135 | this._opts.overwrite_original = '' 136 | } 137 | } 138 | 139 | /** 140 | * Set ExifTool to extract binary tag data. 141 | * @summary Set ExifTool to extract binary tag data. 142 | * @author Matthew Duffy 143 | * @param { Boolean } enabled - True/False value to enable/disable binary tag extraction. 144 | * @return { undefined } 145 | */ 146 | enableBinaryTagOutput(enabled) { 147 | const log = debug.extend('enableBinaryTagOutput') 148 | if (enabled) { 149 | log('Enabling binary output.') 150 | this._opts.binaryFormat = '-binary' 151 | } else { 152 | log('Disabling binary output.') 153 | this._opts.binaryFormat = '' 154 | } 155 | this.setCommand() 156 | } 157 | 158 | /** 159 | * Set ExifTool output format. 160 | * @summary Set Exiftool output format. 161 | * @author Matthew Duffy 162 | * @param { String } [fmt='json'] - Output format to set, default is JSON, but can be XML. 163 | * @return { Boolean } - Return True if new format is set, False otherwise. 164 | */ 165 | setOutputFormat(fmt = 'json') { 166 | const log = debug.extend('setOutputFormat') 167 | const err = error.extend('setOutputFormat') 168 | let newFormat 169 | const match = fmt.match(/(?xml|json)/i) 170 | if (match || match.groups?.format) { 171 | newFormat = (match.groups.format === 'xml') ? '-xmlFormat' : '-json' 172 | this._opts.outputFormat = newFormat 173 | log(`Output format is set to ${this._opts.outputFormat}`) 174 | this.setCommand() 175 | return true 176 | } 177 | err(`Output format ${fmt} not supported.`) 178 | return false 179 | } 180 | 181 | /** 182 | * Set ExifTool output formatting for GPS coordinate data. 183 | * @summary Set ExifTool output formatting for GPS coordinate data. 184 | * @author Matthew Duffy 185 | * @param { String } [fmt=default] - printf format string with specifiers for degrees, minutes and seconds. 186 | * @see {@link https://exiftool.org/exiftool_pod.html#c-FMT--coordFormat} 187 | * @return { undefined } 188 | */ 189 | setGPSCoordinatesOutputFormat(fmt = 'default') { 190 | const log = debug.extend('setGPSCoordinatesOutputFormat') 191 | const groups = fmt.match(/(?\+)?(?gps)/i)?.groups 192 | if (fmt.toLowerCase() === 'default') { 193 | // revert to default formatting 194 | this._opts.coordFormat = '' 195 | } else if (groups?.gps === 'gps') { 196 | this._opts.coordFormat = `-coordFormat %${(groups?.signed ? '+' : '')}.6f` 197 | } else { 198 | this._opts.coordFormat = `-coordFormat ${fmt}` 199 | } 200 | log(`GPS format is now ${fmt}`) 201 | } 202 | 203 | /** 204 | * Set ExifTool to extract xmp struct tag data. 205 | * @summary Set ExifTool to extract xmp struct tag data. 206 | * @author Matthew Duffy 207 | * @param { Boolean } enabled - True/False value to enable/disable xmp struct tag extraction. 208 | * @return { undefined } 209 | */ 210 | enableXMPStructTagOutput(enabled) { 211 | const log = debug.extend('enableXMPStructTagOutput') 212 | if (enabled) { 213 | log('Enabling XMP struct output format.') 214 | this._opts.structFormat = '-struct' 215 | } else { 216 | log('Disabling XMP struct output format.') 217 | this._opts.structFormat = '' 218 | } 219 | } 220 | 221 | /** 222 | * Tell exiftool to use the Metadata Working Group (MWG) module for overlapping EXIF, IPTC, and XMP tqgs. 223 | * @summary Tell exiftool to use the MWG module for overlapping tag groups. 224 | * @author Matthew Duffy 225 | * @param { Boolean } - True/false value to enable/disable mwg module. 226 | * @return { undefined } 227 | */ 228 | useMWG(enabled) { 229 | const log = debug.extend('useMWG') 230 | if (enabled) { 231 | log('Enabling MWG.') 232 | this._opts.useMWG = '-use MWG' 233 | } else { 234 | log('Disabling MWG.') 235 | this._opts.useMGW = '' 236 | } 237 | } 238 | 239 | /** 240 | * Set the path for image file or directory of images to process with exiftool. 241 | * @summary Set the path of image file or directory of images to process with exiftool. 242 | * @author Matthew Duffy 243 | * @async 244 | * @param { String } imagePath - A file system path to set for exiftool to process. 245 | * @return { Object } Returns an object literal with success or error messages. 246 | */ 247 | async setPath(imagePath) { 248 | const log = debug.extend('setPath') 249 | const err = error.extend('setPath') 250 | log('setPath method entered') 251 | const o = { value: null, error: null } 252 | if (typeof imagePath === 'undefined' || imagePath === null) { 253 | o.error = 'A path to image or directory is required.' 254 | err(o.error) 255 | return o 256 | } 257 | let pathToImage 258 | if (Array.isArray(imagePath)) { 259 | let temp = imagePath.map((i) => `"${path.resolve('.', i)}"`) 260 | temp = temp.join(' ') 261 | log(`imagePath passed as an Array. Resolving and concatting the paths into a single string: ${temp}`) 262 | pathToImage = temp 263 | } else { 264 | pathToImage = `"${path.resolve('.', imagePath)}"` 265 | } 266 | if (!/^(")?\//.test(pathToImage)) { 267 | // the path parameter must be a fully qualified file path, starting with / 268 | throw new Error('The file system path to image must be a fully qualified path, starting from root /.') 269 | } 270 | try { 271 | this._path = pathToImage 272 | if (/^"/.test(pathToImage)) { 273 | this._fileStats = await stat(pathToImage.slice(1, -1)) 274 | } else { 275 | this._fileStats = await stat(pathToImage) 276 | } 277 | this._isDirectory = this._fileStats.isDirectory() 278 | if (this._fileStats.isDirectory()) { 279 | this._imgDir = pathToImage 280 | } 281 | this.setCommand() 282 | o.value = true 283 | } catch (e) { 284 | err(e) 285 | o.error = e.message 286 | o.errorCode = e.code 287 | o.errorStack = e.stack 288 | } 289 | return o 290 | } 291 | 292 | /** 293 | * Get the fully qualified path to the image (or directory) specified in init. 294 | * @summary Get the full qualified path to the image. 295 | * @author Matthew Duffy 296 | * @async 297 | * @return { Object } Returns an object literal with success or error messages. 298 | */ 299 | async getPath() { 300 | const log = debug.extend('getPath') 301 | const err = error.extend('getPath') 302 | log('getPath method entered') 303 | const o = { value: null, error: null } 304 | if (this._path === null || typeof this._path === 'undefined' || this._path === '') { 305 | o.error = 'Path to an image file or image directory is not set.' 306 | err(o.error) 307 | } else { 308 | o.value = true 309 | o.file = (this._isDirectory) ? null : path.basename(this._path) 310 | o.dir = (this._isDirectory) ? this._path : path.dirname(this._path) 311 | o.path = this._path 312 | } 313 | return o 314 | } 315 | 316 | /** 317 | * Check to see if the exiftool.config file is present at the expected path. 318 | * @summary Check to see if the exiftool.config file is present at the expected path. 319 | * @author Matthew Duffy 320 | * @return { Boolean } Returns True if present, False if not. 321 | */ 322 | async hasExiftoolConfigFile() { 323 | const log = debug.extend('hasExiftoolConfigFile') 324 | const err = error.extend('hasExiftoolConfigFile') 325 | log('hasExiftoolConfigFile method entered') 326 | log('>') 327 | let exists = false 328 | const file = this._exiftool_config 329 | let stats 330 | try { 331 | log('>>') 332 | if (/^"/.test(file)) { 333 | stats = await stat(file.slice(1, -1)) 334 | } else { 335 | stats = await stat(file) 336 | } 337 | log('>>>') 338 | log(stats) 339 | exists = true 340 | } catch (e) { 341 | err('>>>>') 342 | err(e) 343 | exists = false 344 | } 345 | log('>>>>>') 346 | return exists 347 | } 348 | 349 | /** 350 | * Create the exiftool.config file if it is not present. 351 | * @summary Create the exiftool.config file if it is not present. 352 | * @author Matthew Duffy 353 | * @async 354 | * @return { Object } Returns an object literal with success or error messages. 355 | */ 356 | async createExiftoolConfigFile() { 357 | const log = debug.extend('createExiftoolConfigFile') 358 | const err = error.extend('createExiftoolConfigFile') 359 | log('createExiftoolConfigFile method entered') 360 | const o = { value: null, error: null } 361 | const stub = `%Image::ExifTool::UserDefined::Shortcuts = ( 362 | BasicShortcut => ['file:Directory','file:FileName','EXIF:CreateDate','file:MIMEType','exif:Make','exif:Model','exif:ImageDescription','iptc:ObjectName','iptc:Caption-Abstract','iptc:Keywords','Composite:GPSPosition'], 363 | Location => ['EXIF:GPSLatitudeRef', 'EXIF:GPSLatitude', 'EXIF:GPSLongitudeRef', 'EXIF:GPSLongitude', 'EXIF:GPSAltitudeRef', 364 | 'EXIF:GPSSpeedRef', 'EXIF:GPSAltitude', 'EXIF:GPSSpeed', 'EXIF:GPSImgDirectionRef', 'EXIF:GPSImgDirection', 'EXIF:GPSDestBearingRef', 'EXIF:GPSDestBearing', 365 | 'EXIF:GPSHPositioningError', 'Composite:GPSAltitude', 'Composite:GPSLatitude', 'Composite:GPSLongitude', 'Composite:GPSPosition', 'XMP:Location*', 'XMP:LocationCreatedGPSLatitude', 366 | 'XMP:LocationCreatedGPSLongitude', 'XMP:LocationShownGPSLatitude', 'XMP:LocationShownGPSLongitude'], 367 | StripGPS => ['gps:all='], 368 | );` 369 | // let fileName = `${this._cwd}/exiftool.config` 370 | const fileName = this._exiftool_config 371 | const echo = `echo "${stub}" > ${fileName}` 372 | try { 373 | log('attemtping to create exiftool.config file') 374 | const result = cmd(echo) 375 | log(result.stdout) 376 | o.value = true 377 | } catch (e) { 378 | err('failed to create new exiftool.config file') 379 | err(e) 380 | o.error = e.message 381 | o.errorCode = e.code 382 | o.errorStack = e.stack 383 | } 384 | return o 385 | } 386 | 387 | /** 388 | * Set the GPS location to point to a new point. 389 | * @summary Set the GPS location to point to a new point. 390 | * @author Matthew Duffy 391 | * @async 392 | * @param { Object } coordinates - New GPS coordinates to assign to image. 393 | * @param { Number } coordinates.latitude - Latitude component of location. 394 | * @param { Number } coordinates.longitude - Longitude component of location. 395 | * @param { String } [coordinates.city] - City name to be assigned using MWG composite method. 396 | * @param { String } [coordinates.state] - State name to be assigned using MWG composite method. 397 | * @param { String } [coordindates.country] - Country name to be assigned using MWG composite method. 398 | * @param { String } [coordindates.countryCode] - Country code to be assigned using MWG composite method. 399 | * @param { String } [coordinates.location] - Location name to be assigned using MWG composite method. 400 | * @throws { Error } Throws an error if no image is set yet. 401 | * @return { Object } Object literal with stdout or stderr. 402 | */ 403 | async setLocation(coordinates) { 404 | const log = debug.extend('setLocation') 405 | const err = error.extend('setLocation') 406 | if (!this._path) { 407 | throw new Error('No image file set yet.') 408 | } 409 | try { 410 | const lat = parseFloat(coordinates?.latitude) ?? null 411 | const latRef = `${(lat > 0) ? 'N' : 'S'}` 412 | const lon = parseFloat(coordinates?.longitude) ?? null 413 | const lonRef = `${(lon > 0) ? 'E' : 'W'}` 414 | const alt = 10000 415 | const altRef = 0 416 | let command = `${this._executable} ` 417 | if (lat && lon) { 418 | command += `-GPSLatitude=${lat} -GPSLatitudeRef=${latRef} -GPSLongitude=${lon} -GPSLongitudeRef=${lonRef} -GPSAltitude=${alt} -GPSAltitudeRef=${altRef} ` 419 | + `-XMP:LocationShownGPSLatitude=${lat} -XMP:LocationShownGPSLongitude=${lon}` 420 | } 421 | if (coordinates?.city !== undefined) { 422 | command += ` -IPTC:City='${coordinates.city}' -XMP-iptcExt:LocationShownCity='${coordinates.city}' -XMP:City='${coordinates.city}'` 423 | // command += ` -MWG:City='${coordinates.city}'` 424 | } 425 | if (coordinates?.state !== undefined) { 426 | command += ` -IPTC:Province-State='${coordinates.state}' -XMP-iptcExt:LocationShownProvinceState='${coordinates.state}' -XMP:Country='${coordinates.state}'` 427 | // command += ` -MWG:State='${coordinates.state}'` 428 | } 429 | if (coordinates?.country !== undefined) { 430 | command += ` -IPTC:Country-PrimaryLocationName='${coordinates.country}' -XMP:LocationShownCountryName= -XMP:LocationShownCountryName='${coordinates.country}' -XMP:Country='${coordinates.country}'` 431 | // command += ` -MWG:Country='${coordinates.country}'` 432 | } 433 | if (coordinates?.countryCode !== undefined) { 434 | command += ` -IPTC:Country-PrimaryLocationCode='${coordinates.countryCode}' -XMP:LocationShownCountryCode= -XMP:LocationShownCountryCode='${coordinates.countryCode}' -XMP:CountryCode='${coordinates.countryCode}'` 435 | // command += ` -MWG:Country='${coordinates.country}'` 436 | } 437 | if (coordinates?.location !== undefined) { 438 | command += ` -IPTC:Sub-location='${coordinates.location}' -XMP-iptcExt:LocationShownSublocation='${coordinates.location}' -XMP:Location='${coordinates.location}'` 439 | // command += ` -MWG:Location='${coordinates.location}'` 440 | } 441 | command += ` -struct -codedcharacterset=utf8 ${this._path}` 442 | log(command) 443 | const result = await cmd(command) 444 | result.exiftool_command = command 445 | log('set new location: %o', result) 446 | return result 447 | } catch (e) { 448 | err(e) 449 | throw new Error(e) 450 | } 451 | } 452 | 453 | /** 454 | * Set the GPS location to point to null island. 455 | * @summary Set the GPS location to point to null island. 456 | * @author Matthew Duffy 457 | * @async 458 | * @throws { Error } Throws an error if no image is set yet. 459 | * @return { Object } Object literal with stdout or stderr. 460 | */ 461 | async nullIsland() { 462 | const log = debug.extend('nullIsland') 463 | const err = error.extend('nullIsland') 464 | if (!this._path) { 465 | throw new Error('No image file set yet.') 466 | } 467 | try { 468 | const latitude = 0.0 469 | const latRef = 'S' 470 | const longitude = 0.0 471 | const longRef = 'W' 472 | const alt = 10000 473 | const altRef = 0 474 | const command = `${this._executable} -GPSLatitude=${latitude} -GPSLatitudeRef=${latRef} -GPSLongitude=${longitude} -GPSLongitudeRef=${longRef} -GPSAltitude=${alt} -GPSAltitudeRef=${altRef} ${this._path}` 475 | const result = await cmd(command) 476 | result.exiftool_command = command 477 | log('null island: %o', result) 478 | return result 479 | } catch (e) { 480 | err(e) 481 | throw new Error(e) 482 | } 483 | } 484 | 485 | /** 486 | * Set the GPS location to point nemo. 487 | * @summary Set the GPS location to point nemo. 488 | * @author Matthew Duffy 489 | * @async 490 | * @throws { Error } Throws an error if no image is set yet. 491 | * @return { Object } Object literal with stdout or stderr. 492 | */ 493 | async nemo() { 494 | const log = debug.extend('nemo') 495 | const err = error.extend('nemo') 496 | if (!this._path) { 497 | throw new Error('No image file set yet.') 498 | } 499 | try { 500 | const latitude = 22.319469 501 | const latRef = 'S' 502 | const longitude = 114.189505 503 | const longRef = 'W' 504 | const alt = 10000 505 | const altRef = 0 506 | const command = `${this._executable} -GPSLatitude=${latitude} -GPSLatitudeRef=${latRef} -GPSLongitude=${longitude} -GPSLongitudeRef=${longRef} -GPSAltitude=${alt} -GPSAltitudeRef=${altRef} ${this._path}` 507 | const result = await cmd(command) 508 | result.exiftool_command = command 509 | log('nemo: %o', result) 510 | return result 511 | } catch (e) { 512 | err(e) 513 | throw new Error(e) 514 | } 515 | } 516 | 517 | /** 518 | * Strip all location data from the image. 519 | * @summary Strip all location data from the image. 520 | * @author Matthew Duffy 521 | * @async 522 | * @throws { Error } Throws an error if no image is set yet. 523 | * @return { Object } Object literal with stdout or stderr. 524 | */ 525 | async stripLocation() { 526 | const log = debug.extend('stripLocation') 527 | const err = error.extend('stripLocation') 528 | if (!this._path) { 529 | const msg = 'No image file set yet.' 530 | err(msg) 531 | throw new Error(msg) 532 | } 533 | try { 534 | // const tags = '-overwrite_original -gps:all=' 535 | // const tags = `${this._opts.overwrite_original} -gps:all= -XMP:LocationShown= -XMP:LocationCreated= -XMP:LocationShownCity= -XMP:LocationShownCountryCode= -XMP:LocationShownCountryName= ` 536 | const tags = `${this._opts.overwrite_original} -gps:all= -XMP:LocationShown*= -XMP:LocationCreated*= -XMP:Location= -XMP:City= -XMP:Country*= -IPTC:City= -IPTC:Province-State= -IPTC:Sub-location= -IPTC:Country*= ` 537 | const command = `${this._executable} ${tags} ${this._path}` 538 | const result = await cmd(command) 539 | result.exiftool_command = command 540 | log('stripLocation: %o', result) 541 | return result 542 | } catch (e) { 543 | err(e) 544 | throw new Error(e) 545 | } 546 | } 547 | 548 | /** 549 | * Find the path to the executable exiftool binary. 550 | * @summary Find the path to the executable exiftool binary. 551 | * @author Matthew Duffy 552 | * @async 553 | * @return { String|Error } Returns the file system path to exiftool binary, or throws an error. 554 | */ 555 | async which() { 556 | const log = debug.extend('which') 557 | const err = error.extend('which') 558 | if (this._executable !== null) { 559 | return this._executable 560 | } 561 | let which 562 | try { 563 | // test command not founc condition 564 | const exiftool = (!this?._test) ? 'exiftool' : 'exitfool' 565 | // which = await cmd('which exiftool') 566 | which = await cmd(`which ${exiftool}`) 567 | if (which.stdout.slice(-1) === '\n') { 568 | which = which.stdout.slice(0, -1) 569 | this._executable = which 570 | log(`found: ${which}`) 571 | } 572 | } catch (e) { 573 | err(e) 574 | throw new Error('Exiftool not found?', { cause: e }) 575 | } 576 | return which 577 | } 578 | 579 | /** Get the version number of the currently installed exiftool. 580 | * @summary Get the version number of the currently installed exiftool. 581 | * @author Matthew Duffy 582 | * @async 583 | * @returns { String|Error } Returns the version of exiftool as a string, or throws an error. 584 | */ 585 | async version() { 586 | const log = debug.extend('version') 587 | const err = error.extend('version') 588 | if (this._version !== null) { 589 | return this._version 590 | } 591 | let ver 592 | const _exiftool = (this._executable !== null ? this._executable : await this.which()) 593 | try { 594 | ver = await cmd(`${_exiftool} -ver`) 595 | if (ver.stdout.slice(-1) === '\n') { 596 | ver = ver.stdout.slice(0, -1) 597 | this._version = ver 598 | log(`found: ${ver}`) 599 | } 600 | } catch (e) { 601 | err(e) 602 | throw new Error('Exiftool not found?', { cause: e }) 603 | } 604 | return ver 605 | } 606 | 607 | /** 608 | * Set the full command string from the options. 609 | * @summary Set the full command string from the options. 610 | * @author Matthew Duffy 611 | * @return { undefined } 612 | */ 613 | setCommand() { 614 | const log = debug.extend('setCommand') 615 | this._command = `${this._executable} ${this.getOptions()} ${this._path}` 616 | log(`exif command set: ${this._command}`) 617 | } 618 | 619 | /** 620 | * Lexically order the array of file extensions to be excluded from the exiftool query. 621 | * @summary Lexically order the array of file extensions to be excluded from the exiftool query. 622 | * @author Matthew Duffy 623 | * @return { undefined } 624 | */ 625 | orderExcludeTypesArray() { 626 | const log = debug.extend('orderExcludeTypesArray') 627 | this._extensionsToExclude.forEach((ext) => ext.toLowerCase()) 628 | this._extensionsToExclude.sort((a, b) => { 629 | if (a.toLowerCase() < b.toLowerCase()) return -1 630 | if (a.toLowerCase() > b.toLowerCase()) return 1 631 | return 0 632 | }) 633 | log(this._extensionsToExclude) 634 | // this._extensionsToExclude = temp 635 | } 636 | 637 | /** 638 | * Compose the command line string of file type extentions for exiftool to exclude. 639 | * @summary Compose the command line string of file type extensions for exiftool to exclude. 640 | * @author Matthew Duffy 641 | * @return { undefined } 642 | */ 643 | setExcludeTypes() { 644 | const log = debug.extend('setExcludeTypes') 645 | this._extensionsToExclude.forEach((ext) => { this._opts.excludeTypes += `--ext ${ext} ` }) 646 | log(this._extensionsToExclude) 647 | } 648 | 649 | /** 650 | * Get the instance property array of file type extentions for exiftool to exclude. 651 | * @summary Get the instance property array of file type extensions for exiftool to exclude. 652 | * @author Matthew Duffy 653 | * @returns { String[] } The array of file type extentions for exiftool to exclude. 654 | */ 655 | getExtensionsToExclude() { 656 | return this._extensionsToExclude 657 | } 658 | 659 | /** 660 | * Set the array of file type extentions that exiftool should ignore while recursing through a directory. 661 | * @summary Set the array of file type extenstions that exiftool should ignore while recursing through a directory. 662 | * @author Matthew Duffy 663 | * @throws Will throw an error if extensionsArray is not an Array. 664 | * @param { String[] } extensionsToAddArray - An array of file type extensions to add to the exclude list. 665 | * @param { String[] } extensionsToRemoveArray - An array of file type extensions to remove from the exclude list. 666 | * @return { undefined } 667 | */ 668 | setExtensionsToExclude(extensionsToAddArray = null, extensionsToRemoveArray = null) { 669 | const log = debug.extend('setExtensiosToExclude') 670 | // if (extensionsToAddArray !== '' || extensionsToAddArray !== null) { 671 | if (extensionsToAddArray !== null) { 672 | if (extensionsToAddArray.constructor !== Array) { 673 | throw new Error('Expecting an array of file extensions to be added.') 674 | } 675 | extensionsToAddArray.forEach((ext) => { 676 | if (!this._extensionsToExclude.includes(ext.toLowerCase())) { 677 | this._extensionsToExclude.push(ext.toLowerCase()) 678 | } 679 | }) 680 | } 681 | // if (extensionsToRemoveArray !== '' || extensionsToRemoveArray !== null) { 682 | if (extensionsToRemoveArray !== null) { 683 | if (extensionsToRemoveArray.constructor !== Array) { 684 | throw new Error('Expecting an array of file extensions to be removed.') 685 | } 686 | extensionsToRemoveArray.forEach((ext) => { 687 | const index = this._extensionsToExclude.indexOf(ext.toLowerCase()) 688 | if (index > 0) { 689 | this._extensionsToExclude.splice(index, 1) 690 | } 691 | }) 692 | } 693 | this.orderExcludeTypesArray() 694 | this._opts.excludeTypes = '' 695 | this.setExcludeTypes() 696 | log(this._opts.excludeTypes) 697 | } 698 | 699 | /** 700 | * Concatenate all the exiftool options together into a single string. 701 | * @summary Concatenate all the exiftool options together into a single string. 702 | * @author Matthew Duffy 703 | * @return { String } Commandline options to exiftool. 704 | */ 705 | getOptions() { 706 | const log = debug.extend('getOptions') 707 | let tmp = '' 708 | if (this._opts.excludeTypes === '') { 709 | this.setExcludeTypes() 710 | } 711 | // return Object.values(this._opts).join(' ') 712 | Object.keys(this._opts).forEach((key) => { 713 | // log(`checking _opts keys: _opts[${key}]: ${this._opts[key]}`) 714 | if (/overwrite_original/i.test(key)) { 715 | log(`ignoring ${key}`) 716 | log('well, not really for now.') 717 | // tmp += '' 718 | tmp += `${this._opts[key]} ` 719 | } else if (/tagList/i.test(key) && this._opts.tagList === null) { 720 | // log(`ignoring ${key}`) 721 | tmp += '' 722 | } else { 723 | tmp += `${this._opts[key]} ` 724 | } 725 | }) 726 | log('option string: ', tmp) 727 | return tmp 728 | } 729 | 730 | /** 731 | * Set the file system path to a different exiftool.config to be used. 732 | * @summary Set the file system path to a different exiftool.config to be used. 733 | * @author Matthew Duffy 734 | * @async 735 | * @param { String } newConfigPath - A string containing the file system path to a valid exiftool.config file. 736 | * @return { Object } Returns an object literal with success or error messages. 737 | */ 738 | async setConfigPath(newConfigPath) { 739 | const log = debug.extend('setConfigPath') 740 | const o = { value: null, error: null } 741 | if (newConfigPath === '' || newConfigPath === null) { 742 | o.error = 'A valid file system path to an exiftool.config file is required.' 743 | } else { 744 | try { 745 | // const stats = await stat(newConfigPath) 746 | if (/^"/.test(newConfigPath)) { 747 | await stat(newConfigPath.slice(1, -1)) 748 | this._exiftool_config = newConfigPath 749 | } else { 750 | await stat(newConfigPath) 751 | this._exiftool_config = `"${newConfigPath}"` 752 | } 753 | o.value = true 754 | this._opts.exiftool_config = `-config ${this._exiftool_config}` 755 | this.setCommand() 756 | } catch (e) { 757 | o.value = false 758 | o.error = e.message 759 | o.e = e 760 | } 761 | } 762 | log(`Config path set to: ${this._exiftool_config}`) 763 | return o 764 | } 765 | 766 | /** 767 | * Get the instance property for the file system path to the exiftool.config file. 768 | * @summary Get the instance property for the file system path to the exiftool.config file. 769 | * @author Matthew Duffy 770 | * @returns { Object } Returns an object literal with success or error messages. 771 | */ 772 | getConfigPath() { 773 | const log = debug.extend('getConfigPath') 774 | log('getConfigPath method entered') 775 | const o = { value: null, error: null } 776 | if (this._exiftool_config === '' || this._exiftool_config === null || typeof this._exiftool_config === 'undefined') { 777 | o.error = 'No path set for the exiftool.config file.' 778 | } else if (/^"/.test(this._exiftool_config)) { 779 | o.value = this._exiftool_config.slice(1, -1) 780 | } else { 781 | o.value = this._exiftool_config 782 | } 783 | return o 784 | } 785 | 786 | /** 787 | * Check the exiftool.config to see if the specified shortcut exists. 788 | * @summary Check to see if a shortcut exists. 789 | * @author Matthew Duffy 790 | * @param { String } shortcut - The name of a shortcut to check if it exists in the exiftool.config. 791 | * @return { Boolean } Returns true if the shortcut exists in the exiftool.config, false if not. 792 | */ 793 | async hasShortcut(shortcut) { 794 | const log = debug.extend('hasShortcut') 795 | const err = error.extend('hasShortcut') 796 | let exists 797 | if (shortcut === 'undefined' || shortcut === null) { 798 | exists = false 799 | } else { 800 | try { 801 | const re = new RegExp(`${shortcut}`, 'i') 802 | const grep = `grep -i "${shortcut}" ${this._exiftool_config}` 803 | const output = await cmd(grep) 804 | output.grep_command = grep 805 | log('grep -i: %o', output) 806 | const stdout = output.stdout?.match(re) 807 | if (shortcut.toLowerCase() === stdout[0].toLowerCase()) { 808 | exists = true 809 | } else { 810 | exists = false 811 | } 812 | } catch (e) { 813 | err(e) 814 | exists = false 815 | } 816 | } 817 | return exists 818 | } 819 | 820 | /** 821 | * Add a new shortcut to the exiftool.config file. 822 | * @summary Add a new shortcut to the exiftool.config file. 823 | * @author Matthew Duffy 824 | * @async 825 | * @param { String } newShortcut - The string of text representing the new shortcut to add to exiftool.config file. 826 | * @return { Object } Returns an object literal with success or error messages. 827 | */ 828 | async addShortcut(newShortcut) { 829 | const log = debug.extend('addShortcut') 830 | const err = error.extend('addShortcut') 831 | const o = { value: null, error: null } 832 | if (newShortcut === 'undefined' || newShortcut === '') { 833 | o.error = 'Shortcut name must be provided as a string.' 834 | } else { 835 | try { 836 | let sedCommand 837 | if (process.platform === 'darwin') { 838 | /* eslint-disable-next-line no-useless-escape */ 839 | sedCommand = `sed -i'.bk' -e '2i\\ 840 | ${newShortcut},' ${this._exiftool_config}` 841 | } else { 842 | sedCommand = `sed -i.bk "2i\\ ${newShortcut}," ${this._exiftool_config}` 843 | } 844 | log(`sed command: ${sedCommand}`) 845 | const output = await cmd(sedCommand) 846 | log(output) 847 | o.command = sedCommand 848 | if (output.stderr === '') { 849 | o.value = true 850 | } else { 851 | o.value = false 852 | o.error = output.stderr 853 | } 854 | } catch (e) { 855 | err(`Failed to add shortcut, ${newShortcut}, to exiftool.config file`) 856 | err(e) 857 | } 858 | } 859 | return o 860 | } 861 | 862 | /** 863 | * Remove a shorcut from the exiftool.config file. 864 | * @summary Remove a shortcut from the exiftool.config file. 865 | * @author Matthew Duffy 866 | * @async 867 | * @param { String } shortcut - A string containing the name of the shortcut to remove. 868 | * @return { Object } Returns an object literal with success or error messages. 869 | */ 870 | async removeShortcut(shortcut) { 871 | const log = debug.extend('removeShortcut') 872 | const err = error.extend('removeShortcut') 873 | const o = { value: null, error: null } 874 | if (shortcut === 'undefined' || shortcut === '') { 875 | o.error = 'Shortcut name must be provided as a string.' 876 | } else { 877 | try { 878 | const sedCommand = `sed -i.bk "/${shortcut}/d" ${this._exiftool_config}` 879 | o.command = sedCommand 880 | log(`sed command: ${sedCommand}`) 881 | const output = await cmd(sedCommand) 882 | log(output) 883 | if (output.stderr === '') { 884 | o.value = true 885 | } else { 886 | o.value = false 887 | o.error = output.stderr 888 | } 889 | } catch (e) { 890 | err(`Failed to remove shortcut, ${shortcut}, from the exiftool.config file.`) 891 | err(e) 892 | } 893 | } 894 | return o 895 | } 896 | 897 | /** 898 | * Clear the currently set exiftool shortcut. No shortcut means exiftool returns all tags. 899 | * @summary Clear the currently set exiftool shortcut. 900 | * @author Matthew Duffy 901 | * @return { undefined } 902 | */ 903 | clearShortcut() { 904 | const log = debug.extend('clearShortcut') 905 | this._opts.shortcut = '' 906 | this.setCommand() 907 | log('Shortcut option cleared.') 908 | } 909 | 910 | /** 911 | * Set a specific exiftool shortcut. The new shortcut must already exist in the exiftool.config file. 912 | * @summary Set a specific exiftool shortcut to use. 913 | * @author Matthew Duffy 914 | * @param { String } shortcut - The name of a new exiftool shortcut to use. 915 | * @return { Object } Returns an object literal with success or error messages. 916 | */ 917 | setShortcut(shortcut) { 918 | const log = debug.extend('setShortcut') 919 | const err = error.extend('setShortcut') 920 | const o = { value: null, error: null } 921 | if (shortcut === undefined || shortcut === null) { 922 | o.error = 'Shortcut must be a string value.' 923 | err(o.error) 924 | } else { 925 | this._opts.shortcut = `-${shortcut}` 926 | this.setCommand() 927 | o.value = true 928 | log(`Shortcut set to: ${this._opts.shortcut}`) 929 | } 930 | return o 931 | } 932 | 933 | /** 934 | * Set one or more explicit metadata tags in the command string for exiftool to extract. 935 | * @summary Set one or more explicit metadata tags in the command string for exiftool to extract. 936 | * @author Matthew Duffy 937 | * @param { String|String[]} tagsToExtract - A string or an array of metadata tags to be passed to exiftool. 938 | * @return { Object } Returns an object literal with success or error messages. 939 | */ 940 | setMetadataTags(tagsToExtract) { 941 | const log = debug.extend('setMetadataTags') 942 | const err = error.extend('setMetadataTags') 943 | let tags 944 | log(`>> ${tagsToExtract}`) 945 | const o = { value: null, error: null } 946 | if (tagsToExtract === 'undefined' || tagsToExtract === '' || tagsToExtract === null) { 947 | o.error = 'One or more metadata tags are required' 948 | err(o.error) 949 | } else { 950 | if (Array === tagsToExtract.constructor) { 951 | log('array of tags') 952 | // check array elements so they all have '-' prefix 953 | tags = tagsToExtract.map((tag) => { 954 | if (!/^-{1,1}[^-]?.+$/.test(tag)) { 955 | return `-${tag}` 956 | } 957 | return tag 958 | }) 959 | log(tags) 960 | // join array elements in to a string 961 | this._opts.tagList = `${tags.join(' ')}` 962 | } 963 | if (String === tagsToExtract.constructor) { 964 | log('string of tags') 965 | if (tagsToExtract.match(/^-/) === null) { 966 | this._opts.tagList = `-${tagsToExtract}` 967 | } 968 | this._opts.tagList = tagsToExtract 969 | } 970 | log(this._opts.tagList) 971 | log(this._command) 972 | this.setCommand() 973 | o.value = true 974 | } 975 | return o 976 | } 977 | 978 | /** 979 | * Run the composed exiftool command to get the requested exif metadata. 980 | * @summary Get the exif metadata for one or more image files. 981 | * @author Matthew Duffy 982 | * @async 983 | * @throws { Error } Throw an error if -all= tag is included in the tagsToExtract parameter. 984 | * @throws { Error } Throw an error if exiftool returns a fatal error via stderr. 985 | * @param { String } [ fileOrDir=null ] - The string path to a file or directory for exiftool to use. 986 | * @param { String } [ shortcut=''] - A string containing the name of an existing shortcut for exiftool to use. 987 | * @param { String } [ tagsToExtract=null ] - A string of one or more metadata tags to pass to exiftool. 988 | * @return { (Object|Error) } JSON object literal of metadata or throws an Error if failed. 989 | */ 990 | async getMetadata(fileOrDir = null, shortcut = '', ...tagsToExtract) { 991 | const log = debug.extend('getMetadata') 992 | const err = error.extend('getMetadata') 993 | if (fileOrDir !== null && fileOrDir !== '') { 994 | await this.setPath(fileOrDir) 995 | } 996 | log(`shortcut: ${shortcut}`) 997 | // if (shortcut !== null && shortcut !== '') { 998 | if (shortcut !== null && shortcut !== '' && shortcut !== false) { 999 | this.setShortcut(shortcut) 1000 | } else if (shortcut === null || shortcut === false) { 1001 | this.clearShortcut() 1002 | } else { 1003 | // leave default BasicShortcut in place 1004 | // this.clearShortcut() 1005 | log(`leaving any currenly set shortcut in place: ${this._opts.shortcut}`) 1006 | } 1007 | if (tagsToExtract.length > 0) { 1008 | if (tagsToExtract.includes('-all= ')) { 1009 | err("Can't include metadata stripping -all= tag in get metadata request.") 1010 | throw new Error("Can't include metadata stripping -all= tag in get metadata reqeust.") 1011 | } 1012 | const options = this.setMetadataTags(tagsToExtract.flat()) 1013 | log(options) 1014 | log(this._opts) 1015 | if (options.error) { 1016 | err(options.error) 1017 | throw new Error('tag list option failed') 1018 | } 1019 | } 1020 | log(this._command) 1021 | try { 1022 | let count 1023 | let metadata = await cmd(this._command) 1024 | if (metadata.stderr !== '') { 1025 | throw new Error(metadata.stderr) 1026 | } 1027 | const match = this._opts.outputFormat.match(/(?xml.*|json)/i) 1028 | if (match && match.groups.format === 'json') { 1029 | metadata = JSON.parse(metadata.stdout) 1030 | count = metadata.length 1031 | metadata.push({ exiftool_command: this._command }) 1032 | metadata.push({ format: 'json' }) 1033 | metadata.push(count) 1034 | } else if (match && match.groups.format === 'xmlFormat') { 1035 | const tmp = [] 1036 | const parser = new fxp.XMLParser() 1037 | const xml = parser.parse(metadata.stdout) 1038 | log(xml) 1039 | tmp.push(xml) 1040 | tmp.push({ raw: metadata.stdout }) 1041 | tmp.push({ format: 'xml' }) 1042 | tmp.push({ exiftool_command: this._command }) 1043 | tmp.push(count) 1044 | metadata = tmp 1045 | } else { 1046 | metadata = metadata.stdout 1047 | } 1048 | log(metadata) 1049 | return metadata 1050 | } catch (e) { 1051 | err(e) 1052 | e.exiftool_command = this._command 1053 | return e 1054 | } 1055 | } 1056 | 1057 | async getThumbnail(image) { 1058 | return this.getThumbnails(image) 1059 | } 1060 | 1061 | /** 1062 | * Extract any embedded thumbnail/preview images. 1063 | * @summary Extract any embedded thumbnail/preview images. 1064 | * @author Matthew Duffy 1065 | * @async 1066 | * @param { String } [image] - The name of the image to get thumbnails from. 1067 | * @throws { Error } Throws an error if getting thumbnail data fails for any reason. 1068 | * @return { Object } Collection of zero or more thumbnails from image. 1069 | */ 1070 | async getThumbnails(image) { 1071 | const log = debug.extend('getThumbnails') 1072 | const err = error.extend('getThumbnails') 1073 | if (image) { 1074 | await this.setPath(image) 1075 | } 1076 | if (this._path === null) { 1077 | const msg = 'No image was specified to write new metadata content to.' 1078 | err(msg) 1079 | throw new Error() 1080 | } 1081 | this.setOutputFormat() 1082 | this.clearShortcut() 1083 | this.enableBinaryTagOutput(true) 1084 | this.setMetadataTags('-Preview:all') 1085 | log(this._command) 1086 | let metadata 1087 | try { 1088 | metadata = await cmd(this._command) 1089 | if (metadata.stderr !== '') { 1090 | err(metadata.stderr) 1091 | throw new Error(metadata.stderr) 1092 | } 1093 | metadata = JSON.parse(metadata.stdout) 1094 | metadata.push({ exiftool_command: this._command }) 1095 | metadata.push({ format: 'json' }) 1096 | } catch (e) { 1097 | err(e) 1098 | e.exiftool_command = this._command 1099 | return e 1100 | } 1101 | // log(metadata) 1102 | return metadata 1103 | } 1104 | 1105 | /** 1106 | * Embed the given thumbnail data into the image. Optionally provide a specific metadata tag target. 1107 | * @summary Embed the given thumbnail data into the image. Optionally provide a specific metadata tag target. 1108 | * @author Matthew Duffy 1109 | * @async 1110 | * @param { String } data - A resolved path to the thumbnail data. 1111 | * @param { String } [image = null] - The target image to receive the thumbnail data. 1112 | * @param { String } [tag = 'EXIF:ThumbnailImage'] - Optional destination tag, if other than the default value. 1113 | * @throws { Error } Throws an error if saving thumbnail data fails for any reason. 1114 | * @return { Object } An object containing success or error messages, plus the exiftool command used. 1115 | */ 1116 | async setThumbnail(data, image = null, tag = 'EXIF:ThumbnailImage') { 1117 | const log = debug.extend('setThumbnail') 1118 | const err = error.extend('setThumbnail') 1119 | if (!data) { 1120 | const msg = 'Missing required data parameter.' 1121 | err(msg) 1122 | throw new Error(msg) 1123 | } 1124 | if (image) { 1125 | await this.setPath(image) 1126 | } 1127 | const dataPath = path.resolve(data) 1128 | // this.setOverwriteOriginal(true) 1129 | this.setOutputFormat() 1130 | this.clearShortcut() 1131 | this.setMetadataTags(`"-${tag}<=${dataPath}"`) 1132 | log(this._command) 1133 | let result 1134 | try { 1135 | result = await cmd(this._command) 1136 | result.exiftool_command = this._command 1137 | result.success = true 1138 | } catch (e) { 1139 | err(e) 1140 | e.exiftool_command = this._command 1141 | } 1142 | log(result) 1143 | return result 1144 | } 1145 | 1146 | /** 1147 | * Extract the raw XMP data as xmp-rdf packet. 1148 | * @summary Extract the raw XMP data as xmp-rdf packet. 1149 | * @author Matthew Duffy 1150 | * @async 1151 | * @throws { Error } Throw an error if exiftool returns a fatal error via stderr. 1152 | * @return { (Object|Error) } JSON object literal of metadata or throws an Error if failed. 1153 | */ 1154 | async getXmpPacket() { 1155 | const log = debug.extend('getXmpPacket') 1156 | const err = error.extend('getXmpPacket') 1157 | let packet 1158 | try { 1159 | const command = `${this._executable} ${this._opts.exiftool_config} -xmp -b ${this._path}` 1160 | packet = await cmd(command) 1161 | if (packet.stderr !== '') { 1162 | err(packet.stderr) 1163 | throw new Error(packet.stderr) 1164 | } 1165 | packet.exiftool_command = command 1166 | // const parser = new fxp.XMLParser() 1167 | // const builder = new fxp.XMLBuilder() 1168 | // packet.xmp = builder.build(parser.parse(packet.stdout)) 1169 | packet.xmp = packet.stdout 1170 | delete packet.stdout 1171 | delete packet.stderr 1172 | } catch (e) { 1173 | err(e) 1174 | e.exiftool_command = this._command 1175 | return e 1176 | } 1177 | log(packet) 1178 | return packet 1179 | } 1180 | 1181 | /** 1182 | * Write a new metadata value to the designated tags. 1183 | * @summary Write a new metadata value to the designated tags. 1184 | * @author Matthew Duffy 1185 | * @async 1186 | * @param { String|String[] } metadataToWrite - A string value with tag name and new value or an array of tag strings. 1187 | * @throws { Error } Throws error if there is no valid path to an image file. 1188 | * @throws { Error } Thros error if the current path is to a directory instead of a file. 1189 | * @throws { Error } Thros error if the expected parameter is missing or of the wrong type. 1190 | * @throws { Error } Thros error if exiftool returns a fatal error via stderr. 1191 | * @return { Object|Error } Returns an object literal with success or error messages, or throws an exception if no image given. 1192 | */ 1193 | async writeMetadataToTag(metadataToWrite) { 1194 | const log = debug.extend('writeMetadataToTag') 1195 | const err = error.extend('writeMetadataToTag') 1196 | const o = { value: null, error: null } 1197 | let tagString = '' 1198 | if (this._path === null) { 1199 | const msg = 'No image was specified to write new metadata content to.' 1200 | err(msg) 1201 | throw new Error() 1202 | } 1203 | if (this._isDirectory) { 1204 | const msg = 'A directory was given. Use a path to a specific file instead.' 1205 | err(msg) 1206 | throw new Error(msg) 1207 | } 1208 | switch (metadataToWrite.constructor) { 1209 | case Array: 1210 | tagString = metadataToWrite.join(' ') 1211 | break 1212 | case String: 1213 | tagString = metadataToWrite 1214 | break 1215 | default: 1216 | throw new Error(`Expected a string or an array of strings. Received: ${metadataToWrite.constructor}`) 1217 | } 1218 | try { 1219 | log(`tagString: ${tagString}`) 1220 | const file = `${this._path}` 1221 | // const write = `${this._executable} ${this._opts.exiftool_config} ${tagString} ${file}` 1222 | const write = `${this._executable} ${this._opts.exiftool_config} ${this._opts.overwrite_original} ${tagString} ${file}` 1223 | o.command = write 1224 | const result = await cmd(write) 1225 | if (result.stdout.trim() === null) { 1226 | throw new Error(`Failed to write new metadata to image - ${file}`) 1227 | } 1228 | o.value = true 1229 | o.stdout = result.stdout.trim() 1230 | } catch (e) { 1231 | err(e) 1232 | o.error = e 1233 | } 1234 | return o 1235 | } 1236 | 1237 | /** 1238 | * Clear the metadata from a tag, but keep the tag rather than stripping it from the image file. 1239 | * @summary Clear the metadata from a tag, but keep the tag rather than stripping it from the image file. 1240 | * @author Matthew Duffy 1241 | * @async 1242 | * @throws { Error } Throws error if there is no valid path to an image file. 1243 | * @throws { Error } Throws error if the current path is to a directory instead of a file. 1244 | * @throws { Error } Throws error if the expected parameter is missing or of the wrong type. 1245 | * @throws { Error } Throws error if exiftool returns a fatal error via stderr. 1246 | * @param { String|String[] } tagsToClear - A string value with tag name or an array of tag names. 1247 | * @return { Object|Error } Returns an object literal with success or error messages, or throws an exception if no image given. 1248 | */ 1249 | async clearMetadataFromTag(tagsToClear) { 1250 | const log = debug.extend('clearMetadataFromTag') 1251 | const err = error.extend('clearMetadataFromTag') 1252 | const o = { value: null, errors: null } 1253 | let tagString = '' 1254 | if (this._path === null) { 1255 | const msg = 'No image was specified to clear metadata from tags.' 1256 | err(msg) 1257 | throw new Error(msg) 1258 | } 1259 | if (this._isDirectory) { 1260 | const msg = 'No image was specified to write new metadata content to.' 1261 | err(msg) 1262 | throw new Error(msg) 1263 | } 1264 | let eMsg 1265 | switch (tagsToClear.constructor) { 1266 | case Array: 1267 | tagString = tagsToClear.join(' ') 1268 | break 1269 | case String: 1270 | tagString = tagsToClear 1271 | break 1272 | default: 1273 | eMsg = `Expected a string or an arrray of strings. Recieved ${tagsToClear.constructor}` 1274 | err(eMsg) 1275 | throw new Error(eMsg) 1276 | } 1277 | try { 1278 | log(`tagString: ${tagString}`) 1279 | const file = `${this._path}` 1280 | const clear = `${this._executable} ${tagString} ${file}` 1281 | o.command = clear 1282 | const result = await cmd(clear) 1283 | if (result.stdout.trim() === null) { 1284 | const msg = `Failed to clear the tags: ${tagString}, from ${file}` 1285 | err(msg) 1286 | throw new Error(msg) 1287 | } 1288 | o.value = true 1289 | o.stdout = result.stdout.trim() 1290 | } catch (e) { 1291 | err(e) 1292 | o.error = e 1293 | } 1294 | return o 1295 | } 1296 | 1297 | /** 1298 | * Run the composed exiftool command to strip all the metadata from a file, keeping a backup copy of the original file. 1299 | * @summary Run the composed exiftool command to strip all the metadata from a file. 1300 | * @author Matthew Duffy 1301 | * @async 1302 | * @throws { Error } Throws error if instance property _path is missing. 1303 | * @throws { Error } Throws error if instance property _isDirectory is true. 1304 | * @throws { Error } Throws error if exiftool returns a fatal error via stderr. 1305 | * @return { (Object|Error) } Returns a JSON object literal with success message or throws an Error if failed. 1306 | */ 1307 | async stripMetadata() { 1308 | const log = debug.extend('stripMetadata') 1309 | const err = error.extend('stripMetadata') 1310 | const o = { value: null, error: null } 1311 | if (this._path === null) { 1312 | const msg = 'No image was specified to strip all metadata from.' 1313 | err(msg) 1314 | throw new Error(msg) 1315 | } 1316 | if (this._isDirectory) { 1317 | const msg = 'A directory was given. Use a path to a specific file instead.' 1318 | err(msg) 1319 | throw new Error(msg) 1320 | } 1321 | // exiftool -all= -o %f_copy%-.4nc.%e copper.jpg 1322 | const file = `${this._path}` 1323 | const strip = `${this._executable} -config ${this._exiftool_config} ${this._opts.overwrite_original} -all= ${file}` 1324 | o.command = strip 1325 | try { 1326 | const result = await cmd(strip) 1327 | log(result) 1328 | if (result.stdout.trim().match(/files updated/) === null) { 1329 | throw new Error(`Failed to strip metadata from image - ${file}.`) 1330 | } 1331 | o.value = true 1332 | if (!this._opts.overwrite_original) { 1333 | o.original = `${file}_original` 1334 | } 1335 | } catch (e) { 1336 | o.value = false 1337 | o.error = e 1338 | err(o) 1339 | } 1340 | return o 1341 | } 1342 | 1343 | /** 1344 | * This method takes a single string parameter which is a fully composed metadata query to be passed directly to exiftool. 1345 | * @summary This method takes a single string parameter which is a fully composed metadata query to be passed directly to exiftool. 1346 | * @author Matthew Duffy 1347 | * @async 1348 | * @throws {Error} Throws error if the single string parameter is not provided. 1349 | * @throws {Error} Throws error if the exiftool command returns a fatal error via stderr. 1350 | * @param { String } query - A fully composed metadata to be passed directly to exiftool. 1351 | * @return { (Object|Error) } JSON object literal of metadata or throws an Error if failed. 1352 | */ 1353 | async raw(query) { 1354 | const log = debug.extend('raw') 1355 | const err = error.extend('raw') 1356 | if (query === '' || typeof query === 'undefined' || query.constructor !== String) { 1357 | const msg = 'No query was provided for exiftool to execute.' 1358 | err(msg) 1359 | throw new Error(msg) 1360 | } 1361 | let command = '' 1362 | const match = query.match(/(^[/?].*exiftool\s)/) 1363 | if (!match) { 1364 | if (this._executable === null) { 1365 | throw new Error('No path to exiftool executable provided. Include exiftool path in query.') 1366 | } 1367 | command = this._executable 1368 | } 1369 | command += ` ${query}` 1370 | try { 1371 | log(`raw query: ${query}`) 1372 | log(`raw command: ${command}`) 1373 | let result = await cmd(command) 1374 | log(result.stdout) 1375 | const tmp = JSON.parse(result.stdout?.trim()) 1376 | const tmperr = result.stderr 1377 | // let result = await _spawn(`'${this._command}'`) 1378 | // let tmp 1379 | // result.stdout.on('data', (data) => { 1380 | // log(`stdout: ${data}`) 1381 | // tmp = JSON.parse(data.trim()) 1382 | // }) 1383 | // let tmperr 1384 | // result.stderr.on('data', (data) => { 1385 | // // tmperr = result.stderr 1386 | // tmperr = data 1387 | // }) 1388 | result = tmp 1389 | result.push({ exiftool_command: command }) 1390 | result.push({ stderr: tmperr }) 1391 | log(result) 1392 | return result 1393 | } catch (e) { 1394 | e.exiftool_command = command 1395 | err(e) 1396 | return e 1397 | } 1398 | } 1399 | } 1400 | -------------------------------------------------------------------------------- /src/which.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module @mattduffy/exiftool 3 | * @author Matthew Duffy 4 | * @file which.js An ESM module exporting the local file system path to exiftool. 5 | */ 6 | import { promisify } from 'node:util' 7 | import { exec } from 'node:child_process' 8 | 9 | const cmd = promisify(exec) 10 | async function which() { 11 | const output = await cmd('which exiftool') 12 | return output.stdout.trim() 13 | } 14 | export const path = await which() 15 | --------------------------------------------------------------------------------