├── __tests__ ├── images │ ├── tickle.txt │ ├── fake.js │ ├── fake.cjs │ ├── fake.mjs │ ├── copper.jpg │ ├── nemo.jpeg │ ├── strip.jpg │ ├── IMG_1820.jpg │ ├── IMG_1820.heic │ ├── nullisland.jpeg │ ├── thumbnail.jpg │ ├── Murph_mild_haze.jpg │ ├── needs-a-thumbnail.jpg │ ├── IPTC-PhotometadataRef-Std2021.1.jpg │ └── fake.json ├── setConfigPathTest │ └── exiftool.config └── exif.test.js ├── .npmignore ├── .eslintrc.cjs ├── src ├── which.js └── index.js ├── LICENSE.txt ├── package.json ├── .gitignore ├── TODO.md ├── jest.config.mjs └── README.md /__tests__/images/tickle.txt: -------------------------------------------------------------------------------- 1 | tickle 2 | -------------------------------------------------------------------------------- /__tests__/images/fake.js: -------------------------------------------------------------------------------- 1 | console.log('fake JS file.') 2 | -------------------------------------------------------------------------------- /__tests__/images/fake.cjs: -------------------------------------------------------------------------------- 1 | console.log('fake CJS file.') 2 | -------------------------------------------------------------------------------- /__tests__/images/fake.mjs: -------------------------------------------------------------------------------- 1 | console.log('fake MJS file.') 2 | -------------------------------------------------------------------------------- /__tests__/images/copper.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/HEAD/__tests__/images/copper.jpg -------------------------------------------------------------------------------- /__tests__/images/nemo.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/HEAD/__tests__/images/nemo.jpeg -------------------------------------------------------------------------------- /__tests__/images/strip.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/HEAD/__tests__/images/strip.jpg -------------------------------------------------------------------------------- /__tests__/images/IMG_1820.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/HEAD/__tests__/images/IMG_1820.jpg -------------------------------------------------------------------------------- /__tests__/images/IMG_1820.heic: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/HEAD/__tests__/images/IMG_1820.heic -------------------------------------------------------------------------------- /__tests__/images/nullisland.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/HEAD/__tests__/images/nullisland.jpeg -------------------------------------------------------------------------------- /__tests__/images/thumbnail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/HEAD/__tests__/images/thumbnail.jpg -------------------------------------------------------------------------------- /__tests__/images/Murph_mild_haze.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/HEAD/__tests__/images/Murph_mild_haze.jpg -------------------------------------------------------------------------------- /__tests__/images/needs-a-thumbnail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/HEAD/__tests__/images/needs-a-thumbnail.jpg -------------------------------------------------------------------------------- /__tests__/images/IPTC-PhotometadataRef-Std2021.1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduffy/exiftool/HEAD/__tests__/images/IPTC-PhotometadataRef-Std2021.1.jpg -------------------------------------------------------------------------------- /__tests__/setConfigPathTest/exiftool.config: -------------------------------------------------------------------------------- 1 | %Image::ExifTool::UserDefined::Shortcuts = ( 2 | BasicShortcut => ['EXIF:LensInfo', 'EXIF:FocalLength', 'File:ImageWidth', 'File:ImageHeight'], 3 | ); 4 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /__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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mattduffy/exiftool", 3 | "version": "1.16.0", 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 | "scripts": { 18 | "test": "DEBUG=exiftool:* node --experimental-vm-modules node_modules/jest/bin/jest.js" 19 | }, 20 | "exports": { 21 | ".": "./src/index.js", 22 | "./which.js": "./src/which.js", 23 | "./package.json": "./package.json" 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.5.3" 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 | -------------------------------------------------------------------------------- /.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/copper.jpg 20 | __tests__/images/gps 21 | __tests__/images/copy* 22 | __tests__/images/*_original 23 | src/exiftool.config* 24 | 25 | # Diagnostic reports (https://nodejs.org/api/report.html) 26 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 27 | 28 | # Runtime data 29 | pids 30 | *.pid 31 | *.seed 32 | *.pid.lock 33 | 34 | # Directory for instrumented libs generated by jscoverage/JSCover 35 | lib-cov 36 | 37 | # Coverage directory used by tools like istanbul 38 | coverage 39 | *.lcov 40 | 41 | # nyc test coverage 42 | .nyc_output 43 | 44 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 45 | .grunt 46 | 47 | # Bower dependency directory (https://bower.io/) 48 | bower_components 49 | 50 | # node-waf configuration 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | build/Release 55 | 56 | # Dependency directories 57 | node_modules/ 58 | jspm_packages/ 59 | 60 | # TypeScript v1 declaration files 61 | typings/ 62 | 63 | # TypeScript cache 64 | *.tsbuildinfo 65 | 66 | # Optional npm cache directory 67 | .npm 68 | 69 | # Optional eslint cache 70 | .eslintcache 71 | 72 | # Microbundle cache 73 | .rpt2_cache/ 74 | .rts2_cache_cjs/ 75 | .rts2_cache_es/ 76 | .rts2_cache_umd/ 77 | 78 | # Optional REPL history 79 | .node_repl_history 80 | 81 | # Output of 'npm pack' 82 | *.tgz 83 | 84 | # Yarn Integrity file 85 | .yarn-integrity 86 | 87 | # dotenv environment variables file 88 | .env 89 | .env.test 90 | 91 | # parcel-bundler cache (https://parceljs.org/) 92 | .cache 93 | 94 | # Next.js build output 95 | .next 96 | 97 | # Nuxt.js build / generate output 98 | .nuxt 99 | dist 100 | 101 | # Gatsby files 102 | .cache/ 103 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 104 | # https://nextjs.org/blog/next-9-1#public-directory-support 105 | # public 106 | 107 | # vuepress build output 108 | .vuepress/dist 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /__tests__/exif.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module @mattduffy/exiftool 3 | * @author Matthew Duffy 4 | * @summary A Jest test suite testing the methods of the Exiftool class. 5 | * @file __tests__/exif.test.js 6 | */ 7 | import path from 'node:path' 8 | import { fileURLToPath } from 'node:url' 9 | import { 10 | copyFile, 11 | mkdir, 12 | rm, 13 | stat, 14 | } from 'node:fs/promises' 15 | import Debug from 'debug' 16 | /* eslint-disable import/extensions */ 17 | import { Exiftool } from '../src/index.js' 18 | import { path as executable } from '../src/which.js' 19 | 20 | const __filename = fileURLToPath(import.meta.url) 21 | const __dirname = path.dirname(__filename) 22 | Debug.log = console.log.bind(console) 23 | const debug = Debug('exiftool:Test') 24 | const error = debug.extend('ERROR') 25 | debug(`exif path: ${executable}`) 26 | debug(Exiftool) 27 | 28 | // Set the items to be used for all the tests here as constants. 29 | 30 | const testsDir = __dirname 31 | const imageDir = `${__dirname}/images` 32 | const image1 = `${imageDir}/copper.jpg` 33 | const image2 = `${imageDir}/IMG_1820.jpg` 34 | const image3 = `${imageDir}/IMG_1820.heic` 35 | const image4 = `${imageDir}/strip.jpg` 36 | const image5 = `${imageDir}/nemo.jpeg` 37 | const image6 = `${imageDir}/nullisland.jpeg` 38 | // const image7 = `${imageDir}/IPTC-PhotometadataRef-Std2021.1.jpg` 39 | const image8 = `${imageDir}/Murph_mild_haze.jpg` 40 | const image9 = `${imageDir}/needs-a-thumbnail.jpg` 41 | const thumbnail = `${imageDir}/thumbnail.jpg` 42 | const spacey = 'SNAPCHAT MEMORIES' 43 | const spaceyPath = `${__dirname}/${spacey}/Murph_mild_haze.jpg` 44 | const RealShortcut = 'BasicShortcut' 45 | const FakeShortcut = 'FakeShortcut' 46 | const NewShortcut = 'MattsNewCut' 47 | const MattsNewCut = "MattsNewCut => ['exif:createdate', 'file:FileName']" 48 | 49 | debug(`testsDir: ${testsDir}`) 50 | debug(`imageDir: ${imageDir}`) 51 | debug(`image1: ${image1}`) 52 | debug(`image2: ${image2}`) 53 | debug(`image3: ${image3}`) 54 | debug(`image4: ${image4}`) 55 | 56 | /* eslint-disable no-undef */ 57 | beforeAll(async () => { 58 | const log = debug.extend('before-all') 59 | const err = error.extend('before-all') 60 | try { 61 | const spaceyDirPath = path.resolve(__dirname, spacey) 62 | log(`Creating test path with spaces: ${spaceyDirPath}`) 63 | await mkdir(spaceyDirPath, { recursive: true }) 64 | log(`${spaceyDirPath} exists? ${(await stat(spaceyDirPath)).isDirectory()}`) 65 | const spaceyDirPathFile = path.resolve(spaceyDirPath, 'Murph_mild_haze.jpg') 66 | log(spaceyDirPathFile) 67 | await copyFile(image8, spaceyDirPathFile) 68 | const spaceyConfigPath = path.resolve(__dirname, 'setConfigPathTest', spacey) 69 | log(spaceyConfigPath) 70 | await mkdir(spaceyConfigPath, { recursive: true }) 71 | const src = path.resolve(__dirname, 'setConfigPathTest', 'exiftool.config') 72 | const dest = path.resolve(spaceyConfigPath, 'exiftool.config') 73 | log(`copy ${src} -> ${dest}`) 74 | await copyFile(src, dest) 75 | } catch (e) { 76 | err(e) 77 | } 78 | }) 79 | 80 | afterAll(async () => { 81 | const log = debug.extend('after-all') 82 | const err = error.extend('after-all') 83 | const dir = __dirname.split('/') 84 | const file = `${dir.slice(0, dir.length - 1).join('/')}/exiftool.config` 85 | log(dir) 86 | try { 87 | await rm(`${file}.bk`) 88 | } catch (e) { 89 | err(e) 90 | } 91 | try { 92 | await rm(`${file}.test`) 93 | } catch (e) { 94 | err(e) 95 | } 96 | try { 97 | await rm( 98 | path.resolve(__dirname, 'setConfigPathTest', spacey), 99 | { recursive: true, force: true }, 100 | ) 101 | } catch (e) { 102 | err(e) 103 | } 104 | try { 105 | await rm(path.resolve(__dirname, spacey), { recursive: true, force: true }) 106 | } catch (e) { 107 | err(e) 108 | } 109 | }) 110 | 111 | describe('Exiftool metadata extractor', () => { 112 | test('it should be an instance of Exiftool', () => { 113 | expect(new Exiftool()).toBeInstanceOf(Exiftool) 114 | }) 115 | 116 | test('setExtensionsToExclude: update array of file type extensions to exclude', async () => { 117 | const log = debug.extend('test-01-setExtensionsToExclude') 118 | let img = new Exiftool() 119 | const extensionsArray = img.getExtensionsToExclude() 120 | extensionsArray.push('CONFIG') 121 | log(extensionsArray) 122 | img.setExtensionsToExclude(extensionsArray) 123 | img = await img.init(image1) 124 | const excludes = img._opts.excludeTypes 125 | expect(excludes).toMatch(/CONFIG/) 126 | }) 127 | 128 | test('init: should fail without a path arguement', async () => { 129 | const log = debug.extend('test-02-init-should-fail') 130 | let img = new Exiftool() 131 | log('no init arguments passed') 132 | expect(img = await img.init()).toBeFalsy() 133 | }) 134 | 135 | test('init: with a path should return a configured exiftool', async () => { 136 | const log = debug.extend('test-03-init-pass') 137 | expect.assertions(2) 138 | let img = new Exiftool() 139 | img = await img.init(image1) 140 | log(`init argument: ${image1}`) 141 | expect(img._isDirectory).toBeDefined() 142 | expect(img).toHaveProperty('_fileStats') 143 | }) 144 | 145 | test('which: exiftool is accessible in the path', async () => { 146 | const log = debug.extend('test-04-which') 147 | const img = new Exiftool() 148 | const exif = await img.which() 149 | log(exif) 150 | expect(exif).toMatch(/exiftool/) 151 | }) 152 | 153 | test( 154 | 'get/setConfigPath: change the file system path to the exiftool.config file', 155 | async () => { 156 | const log = debug.extend('test-05-get/setConfigPath') 157 | expect.assertions(4) 158 | const img = new Exiftool() 159 | // setConfigPathTest/exiftool.config 160 | const newConfigFile = `${__dirname}/setConfigPathTest/exiftool.config` 161 | log(newConfigFile) 162 | const oldConfigFile = img.getConfigPath() 163 | expect(oldConfigFile.value).toMatch(/exiftool\/src\/exiftool.config$/) 164 | 165 | const result = await img.setConfigPath(newConfigFile) 166 | expect(result.value).toBeTruthy() 167 | expect(img._command).toMatch(/setConfigPathTest/) 168 | 169 | // test a bad file path 170 | // setConfigPathTest/bad/exiftool.config 171 | const badConfigFile = `${__dirname}/setConfigPathTest/bad/exiftool.config` 172 | const badResult = await img.setConfigPath(badConfigFile) 173 | expect(badResult.e.code).toMatch(/ENOENT/) 174 | }, 175 | ) 176 | 177 | test('setPath: is the path to file or directory', async () => { 178 | const log = debug.extend('test-06-setPath') 179 | expect.assertions(5) 180 | const img = new Exiftool() 181 | // missing path value 182 | const result1 = await img.setPath() 183 | expect(result1.value).toBeNull() 184 | expect(result1.error).toBe('A path to image or directory is required.') 185 | const result2 = await img.setPath(image1) 186 | expect(result2.value).toBeTruthy() 187 | expect(result2.error).toBeNull() 188 | 189 | /* Relative paths are now acceptable */ 190 | // test with a relative path to generate an error 191 | try { 192 | const img1 = new Exiftool() 193 | const newPath = '__tests__/images/copper.jpg' 194 | const result3 = await img1.setPath(newPath) 195 | log(await img1.getPath()) 196 | log(result3) 197 | expect(result3.error).toBeNull() 198 | } catch (e) { 199 | expect(e).toBeInstanceOf(Error) 200 | } 201 | }) 202 | 203 | test('hasExiftoolConfigFile: check if exiftool.config file is present', async () => { 204 | const log = debug.extend('test-07-hasExiftoolConfigFile') 205 | expect.assertions(2) 206 | const img1 = new Exiftool() 207 | log('hasExiftoolConfigFile check - good check') 208 | expect(await img1.hasExiftoolConfigFile()).toBeTruthy() 209 | 210 | const img2 = new Exiftool() 211 | img2._exiftool_config = `${img2._cwd}/exiftool.config.missing` 212 | log('hasExiftoolConfigFile check - bad check') 213 | expect(await img2.hasExiftoolConfigFile()).toBeFalsy() 214 | }) 215 | 216 | test('createExiftoolConfigFile: can create new exiftool.config file', async () => { 217 | const log = debug.extend('test-08-createExiftoolConfigFile') 218 | expect.assertions(2) 219 | let img = new Exiftool() 220 | img = await img.init(testsDir) 221 | img._exiftool_config = `${img._cwd}/exiftool.config.test` 222 | const result = await img.createExiftoolConfigFile() 223 | log(img.getConfigPath()) 224 | expect(result.value).toBeTruthy() 225 | expect(img.hasExiftoolConfigFile()).toBeTruthy() 226 | }) 227 | 228 | test('hasShortcut: check exiftool.config for a shortcut', async () => { 229 | const log = debug.extend('test-09-hasShortcut') 230 | expect.assertions(2) 231 | const img = new Exiftool() 232 | const result1 = await img.hasShortcut(RealShortcut) 233 | log(result1) 234 | expect(result1).toBeTruthy() 235 | const result2 = await img.hasShortcut(FakeShortcut) 236 | expect(result2).toBeFalsy() 237 | }) 238 | 239 | test('addShortcut: add a new shortcut to the exiftool.config file', async () => { 240 | const log = debug.extend('test-10-addShortcut') 241 | expect.assertions(4) 242 | const img1 = new Exiftool() 243 | const result1 = await img1.addShortcut(MattsNewCut) 244 | log(result1) 245 | expect(result1.value).toBeTruthy() 246 | expect(result1.error).toBeNull() 247 | 248 | // check if new shortcut exists and can be returned 249 | let img2 = new Exiftool() 250 | img2 = await img2.init(image1) 251 | const result2 = await img2.hasShortcut(NewShortcut) 252 | expect(result2).toBeTruthy() 253 | 254 | // get metadata using new shortcut 255 | img2.setShortcut(NewShortcut) 256 | log(img2._command) 257 | const metadata = await img2.getMetadata() 258 | log(metadata) 259 | expect(metadata).not.toBeNull() 260 | }) 261 | 262 | test('removeShortcut: remove a given shortcut from the exiftool.config file', async () => { 263 | const log = debug.extend('test-11-removeShortcut') 264 | const img1 = new Exiftool() 265 | const result1 = await img1.removeShortcut(NewShortcut) 266 | log(result1) 267 | expect(result1.value).toBeTruthy() 268 | }) 269 | 270 | test('getMetadata: specify tag list as an optional parameter', async () => { 271 | const log = debug.extend('test-12-getMetadata-specify-tags') 272 | expect.assertions(3) 273 | // test adding additional tags to the command 274 | let img1 = new Exiftool() 275 | // init with the copper.jpg image1 276 | img1 = await img1.init(image1) 277 | const result1 = await img1.getMetadata( 278 | '', 279 | '', 280 | ['file:FileSize', 'file:DateTimeOriginal', 'file:Model'], 281 | ) 282 | const count = parseInt(result1.slice(-1)[0], 10) 283 | log(count) 284 | expect(count).toBe(1) 285 | expect(result1[0]).toHaveProperty('File:FileSize') 286 | expect(result1[0]).toHaveProperty('EXIF:ImageDescription') 287 | }) 288 | 289 | test('getMetadata: specify new file name and tag list as an optional parameter', async () => { 290 | const log = debug.extend('test-13-getMetadata-new-file') 291 | // test changing the file from one set in init() 292 | expect.assertions(2) 293 | let img2 = new Exiftool() 294 | // init with copper.jpg image1 295 | img2 = await img2.init(image1) 296 | // replace image1 with IMG_1820.jpg 297 | const result2 = await img2.getMetadata( 298 | image2, 299 | '', 300 | ['file:FileSize', 'file:DateTimeOriginal', 'file:ImageSize'], 301 | ) 302 | log(result2[0]) 303 | expect(result2[0]).toHaveProperty('File:FileSize') 304 | expect(result2[0]).toHaveProperty('Composite:GPSPosition') 305 | }) 306 | 307 | test( 308 | 'getMetadata: specify new shortcut name and tag list as an optional parameter', 309 | async () => { 310 | const log = debug.extend('test-14-getMetadata-new-shortcut') 311 | // test passing a new shortcut name 312 | let img3 = new Exiftool() 313 | // image3 is IMG_1820.heic 314 | img3 = await img3.init(image3) 315 | const result3 = await img3.getMetadata( 316 | '', 317 | NewShortcut, 318 | ['file:FileSize', 'file:ImageSize'], 319 | ) 320 | log(result3[0]['file:FileSize']) 321 | expect(result3[0]).toHaveProperty('SourceFile') 322 | }, 323 | ) 324 | 325 | test('getMetadata: catch the forbidden -all= data stripping tag', async () => { 326 | const log = debug.extend('test-15-getMetadata-catch-forbidden-tag') 327 | // test catching the -all= stripping tag in get request 328 | let img4 = new Exiftool() 329 | // init with the copper.jpg image1 330 | img4 = await img4.init(image1) 331 | try { 332 | await img4.getMetadata('', '', '-all= ') 333 | } catch (e) { 334 | log(e) 335 | expect(e).toBeInstanceOf(Error) 336 | } 337 | }) 338 | 339 | test( 340 | 'stripMetadata: strip all the metadata out of a file and keep a backup of the ' 341 | + 'original file', 342 | async () => { 343 | const log = debug.extend('test-16-stripMetadata') 344 | log() 345 | // test stripping all metadata from an image file 346 | expect.assertions(2) 347 | let img1 = new Exiftool() 348 | // init with strip.jpg image 4 349 | img1 = await img1.init(image4) 350 | const result = await img1.stripMetadata() 351 | expect(result.value).toBeTruthy() 352 | expect(result.original).toMatch(/_original/) 353 | }, 354 | ) 355 | 356 | test('writeMetadataToTag: write new metadata to one of more designate tags', async () => { 357 | const log = debug.extend('test-17-writeMetadataToTag') 358 | // test writing new metadata to a designated tag 359 | let img1 = new Exiftool() 360 | // init with copper.jpg image1 361 | img1 = await img1.init(image1) 362 | const data1 = '-IPTC:Headline="Wow, Great Photo!" -IPTC:Keywords+=TEST' 363 | log(data1) 364 | const result1 = await img1.writeMetadataToTag(data1) 365 | expect.assertions(3) 366 | expect(result1).toHaveProperty('value', true) 367 | expect(result1.stdout.trim()).toMatch(/1 image files updated/) 368 | 369 | // test writing new metadata to more than one designated tag 370 | let img2 = new Exiftool() 371 | // init with strip.jpg image 4 372 | img2 = await img2.init(image4) 373 | try { 374 | await img2.writeMetadataToTag() 375 | } catch (e) { 376 | expect(e).toBeInstanceOf(Error) 377 | } 378 | }) 379 | 380 | test('clearMetadataFromTag: clear metadata from one or more designated tags', async () => { 381 | const log = debug.extend('test-18-clearMetadataFromTag') 382 | // test clearing metadata values from a designated tag 383 | let img1 = new Exiftool() 384 | // init with strip.jpg image4 385 | img1 = await img1.init(image4) 386 | const data1 = '-IPTC:Headline="Wow, Great Photo!" -IPTC:Contact=TEST' 387 | log(data1) 388 | await img1.writeMetadataToTag(data1) 389 | const tag = ['-IPTC:Headline^=', '-IPTC:Contact^='] 390 | const result1 = await img1.clearMetadataFromTag(tag) 391 | expect(result1.stdout.trim()).toMatch(/1 image files updated/) 392 | }) 393 | 394 | test( 395 | 'raw: send a fully composed exiftool command, bypassing instance config defualts', 396 | async () => { 397 | const log = debug.extend('test-19-raw') 398 | // test sending a raw exiftool command 399 | const img1 = new Exiftool() 400 | const command = `${executable} -G -json -EXIF:ImageDescription -IPTC:ObjectName ` 401 | + `-IPTC:Keywords ${image1}` 402 | log(command) 403 | const result1 = await img1.raw(command) 404 | expect(result1[0]).toHaveProperty('IPTC:ObjectName') 405 | }, 406 | ) 407 | 408 | test('nemo: set the gps location to point nemo', async () => { 409 | const log = debug.extend('test-20-nemo') 410 | // test set location to point nemo 411 | const img1 = await new Exiftool().init(image5) 412 | img1.setGPSCoordinatesOutputFormat('gps') 413 | await img1.nemo() 414 | const result1 = await img1.getMetadata('', null, '-GPS:all') 415 | log(result1[0]['EXIF:GPSLatitude']) 416 | expect.assertions(2) 417 | expect(result1[0]).toHaveProperty('EXIF:GPSLatitude') 418 | expect(Number.parseFloat(result1[0]['EXIF:GPSLatitude'])).toEqual(22.319469) 419 | }) 420 | 421 | test('null island: set the gps location to null island', async () => { 422 | const log = debug.extend('test-21-null-island') 423 | // test set location to null island 424 | const img1 = await new Exiftool().init(image6) 425 | img1.setGPSCoordinatesOutputFormat('gps') 426 | await img1.nullIsland() 427 | const result1 = await img1.getMetadata('', null, '-GPS:all') 428 | expect(result1[0]).toHaveProperty('EXIF:GPSLatitude') 429 | log(result1[0]['EXIF:GPSLatitude']) 430 | expect(Number.parseFloat(result1[0]['EXIF:GPSLatitude'])).toEqual(0) 431 | expect.assertions(2) 432 | }) 433 | 434 | test('set output format to xml', async () => { 435 | const log = debug.extend('test-22-output-to-xml') 436 | log() 437 | const img8 = await new Exiftool().init(image8) 438 | const shouldBeTrue = img8.setOutputFormat('xml') 439 | expect(shouldBeTrue).toBeTruthy() 440 | const result1 = await img8.getMetadata('', null, '-File:all') 441 | expect(result1[1].raw.slice(0, 5)).toMatch(' { 447 | const log = debug.extend('test-23-xmp-packet') 448 | const img8 = await new Exiftool().init(image8) 449 | const packet = await img8.getXmpPacket() 450 | log(packet) 451 | const pattern = /^<\?xpacket .*\?>.*/ 452 | expect(packet.xmp).toMatch(pattern) 453 | }) 454 | 455 | test('exiftool command not found', async () => { 456 | const log = debug.extend('test-24-command-not-found') 457 | log() 458 | const exiftoolNotFound = new Exiftool(null, true) 459 | try { 460 | await exiftoolNotFound.init(image1) 461 | } catch (e) { 462 | expect(e.message).toEqual(expect.stringMatching(/attention/i)) 463 | } 464 | const exiftool = await new Exiftool().init(image1) 465 | expect(exiftool._executable).toEqual(expect.stringMatching(/.+\/exiftool?/)) 466 | }) 467 | 468 | test('handle spaces in image path', async () => { 469 | const log = debug.extend('test-25-spaces-in-image-path') 470 | try { 471 | const exiftool = await new Exiftool().init(spaceyPath) 472 | const metadata = await exiftool.getMetadata('', 'Preview:all') 473 | expect(metadata).toHaveLength(4) 474 | } catch (e) { 475 | log(e) 476 | } 477 | }) 478 | 479 | test('handle spaces in config file path', async () => { 480 | const log = debug.extend('test-26-spaces-in-config-file-path') 481 | const exiftool = await new Exiftool().init(spaceyPath) 482 | const newConfigFile = `${__dirname}/setConfigPathTest/SNAPCHAT MEMORIES/exiftool.config` 483 | log(newConfigFile) 484 | const result = await exiftool.setConfigPath(newConfigFile) 485 | log(result) 486 | expect.assertions(2) 487 | expect(result.value).toBeTruthy() 488 | expect(exiftool._command).toMatch(/SNAPCHAT/) 489 | }) 490 | 491 | test('use getThumbnails() method to extract preview image data', async () => { 492 | const log = debug.extend('test-27-get-thumbnails') 493 | const err = error.extend('test-27-get-thumbnails') 494 | let exiftool 495 | let thumbs 496 | try { 497 | exiftool = await new Exiftool().init(image8) 498 | thumbs = await exiftool.getThumbnails() 499 | log(thumbs[0]['EXIF:ThumbnailImage']) 500 | } catch (e) { 501 | err(e) 502 | } 503 | expect(thumbs.length).toBeGreaterThanOrEqual(3) 504 | expect.assertions(1) 505 | }) 506 | 507 | test('use setThumbnail() method to embed preview image', async () => { 508 | const log = debug.extend('test-28-set-thumbnail') 509 | const err = error.extend('test-28-set-thumbnail') 510 | const exiftool = await new Exiftool().init(image9) 511 | let result 512 | log(`needs-a-thumbnail.jpg: ${image9}`) 513 | log(`thumbnail: ${thumbnail}`) 514 | try { 515 | result = await exiftool.setThumbnail(thumbnail) 516 | } catch (e) { 517 | err(e) 518 | } 519 | expect(result.success).toBeTruthy() 520 | expect.assertions(1) 521 | }) 522 | 523 | test('default stdio buffer size: 10 x (1024 x 1024) bytes.', async () => { 524 | const log = debug.extend('test-29-get-maxBuffer') 525 | const img = new Exiftool() 526 | const defaultBufferSize = img.getOutputBufferSize() 527 | log('default stdio buffer size: ', defaultBufferSize) 528 | expect(Number.parseInt(defaultBufferSize, 10)).toEqual(10 * (1024 * 1024)) 529 | }) 530 | 531 | test('set stdio buffer size to : 20 x (1024 x 1024) bytes.', async () => { 532 | const log = debug.extend('test-30-set-maxBuffer') 533 | const img = new Exiftool() 534 | img.setMaxBufferMultiplier(20) 535 | const newBufferSize = img.getOutputBufferSize(20) 536 | log('new stdio buffer size: ', newBufferSize) 537 | expect(Number.parseInt(newBufferSize, 10)).toEqual(20 * (1024 * 1024)) 538 | }) 539 | }) 540 | -------------------------------------------------------------------------------- /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 | ### Increasing stdio Output Buffer Size 100 | 101 | Images are getting really BIG now. This means you may have to adjust the size of the `stdio` output buffer for `exiftool` to handle multiple large images at a time. `nodejs` uses a default value of 1024 x 1024 = 1,048,576 Bytes (1 megabyte). This may be insufficient for modern high resolution images. `exiftool` increases this minimum default to (1024 x 1024) x 10 = 10,485,760 Bytes (10 megabytes). If you would like to increase this value even more, use the ```setMaxBufferMultiplier()``` method before calling the ```getMetadata()``` method. 102 | 103 | ```javascript 104 | let exiftool = new Exiftool() 105 | exiftool.setMaxBufferMultiplier(20) 106 | const newBufferSize = exifTool.getOutputBufferSize() 107 | console.log(newBufferSize) 108 | // 20971520 Bytes (20 megabytes) 109 | ``` 110 | 111 | #### Extracting Binary Tag Data 112 | 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. 113 | 114 | ```javascript 115 | let exiftool = await new Exiftool().init( 'images/copper.jpg' ) 116 | exiftool.enableBinaryTagOutput(true) 117 | let metadata = await exiftool.getMetadata() 118 | let thumbnail = metadata[0]['EXIF:ThumbnailImage'] 119 | console.log(thumbnail) 120 | // 'base64:/9j/4AAQSkZJRgABAgEASABIAAD/4QKkaHR.........' 121 | ``` 122 | 123 | #### Embedded Thumbnail Images 124 | 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. 125 | ```javascript 126 | let exiftool = await new Exiftool().init( 'images/copper.jgp' ) 127 | const thumbnails = await exiftool.getThumbnails() 128 | console.log(thumbnails) 129 | // [ 130 | // { 131 | // SourceFile: '/www/images/copper.jpg', 132 | // 'EXIF:ThumbnailImage': 'base64:/9j/wAA...' 133 | // }, 134 | // { 135 | // 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"' 136 | // }, 137 | // { format: 'json' }, 138 | // ] 139 | ``` 140 | 141 | 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. 142 | ```javascript 143 | let exiftool = await new Exiftool().init( 'images/copper.jgp' ) 144 | const result = await exiftool.setThumbnails('images/new-thumbnail.jpg') 145 | console.log(result) 146 | // { 147 | // stdout: '', 148 | // stderr: '', 149 | // exiftool_command: '/usr/local/bin/exiftool -config "/../exiftool/src/exiftool.config" -json "-EXIF:ThumbnailImage<=/www/images/new-thumbnail.jpg" "/www/images/copper.jpg"', 150 | // success: true 151 | // } 152 | ``` 153 | 154 | #### Metadata Output Format 155 | 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. 156 | 157 | ```javascript 158 | let exiftool = await new Exiftool().init( 'images/copper.jpg' ) 159 | exiftool.setOutputFormat('xml') 160 | let xml = await exiftool.getMetadata() 161 | console.log(xml) 162 | // [ 163 | // " 164 | // 165 | // 169 | // 3.3 MB 170 | // JPEG 171 | // ... 172 | // YCbCr4:2:0 (2 2) 173 | // 174 | // ", 175 | // { raw: "..." }, 176 | // { format: 'xml' }, 177 | // { 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'}, 178 | // 1, 179 | // ] 180 | ``` 181 | 182 | #### Location Coordinate Output Formatting 183 | 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. 184 | 185 | ```javascript 186 | let exiftool = await new Exiftool().init( 'images/copper.jpg' ) 187 | let defaultLocationFormat = await exiftool.getMetadata('', null, 'EXIF:GPSLongitude', 'EXIF:GPSLongitudeRef') 188 | console.log(defaultLocationFormat[0]['EXIF:GPSLongitude'], defaultLocationFormat[0]['EXIF:GPSLongitudeRef']) 189 | // 122 deg 15' 16.51" West 190 | exiftool.setGPSCoordinatesOutputFormat('gps') 191 | // or exiftool.setGPSCoordinatesOutputFormat('+gps') for signed lat/lon values in Composite:GPS* tags 192 | let myLocationFormat = await exiftool.getMetadata('', null, 'EXIF:GPSLongitude', 'EXIF:GPSLongitudeRef') 193 | console.log(myLocationFormat[0]['EXIF:GPSLongitude'], myLocationFormat[0]['EXIF:GPSLongitudeRef']) 194 | // 122.254586 West 195 | ``` 196 | 197 | #### Raw XMP Packet Data 198 | 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. 199 | 200 | ```javascript 201 | let exiftool = await new Exiftool().init( 'images/copper.jpg' ) 202 | let xmpPacket = await exiftool.getXmpPacket() 203 | console.log(xmpPacket) 204 | // { 205 | // exiftool_command: '/usr/local/bin/exiftool -config /app/src/exiftool.config -xmp -b /www/images/copper.jpg', 206 | // xmp: '' 209 | // } 210 | ``` 211 | 212 | #### XMP Structured Tags 213 | 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. 214 | 215 | ```javascript 216 | let exiftool = await new Exiftool().init( 'images/copper.jpg' ) 217 | exiftool.enableXMPStructTagOutput(true) 218 | let metadata = await exiftool.getMetadata() 219 | ``` 220 | 221 | #### MWG Composite Tags 222 | 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. 223 | 224 | ```javascript 225 | let exiftool = await new Exiftool().init( 'images/IPTC-PhotometadataRef-Std2021.1.jpg' ) 226 | exiftool.useMWG(true) 227 | let metadata = await exiftool.getMetadata('', '', '-MWG:*') 228 | console.log(metadata[0]) 229 | // [ 230 | // { 231 | // SourceFile: 'images/IPTC-PhotometadataRef-Std2021.1.jpg', 232 | // 'Composite:City': 'City (Location shown1) (ref2021.1)', 233 | // 'Composite:Country': 'CountryName (Location shown1) (ref2021.1)', 234 | // 'Composite:Copyright': 'Copyright (Notice) 2021.1 IPTC - www.iptc.org (ref2021.1)', 235 | // 'Composite:Description': 'The description aka caption (ref2021.1)', 236 | // 'Composite:Keywords': [ 'Keyword1ref2021.1', 'Keyword2ref2021.1', 'Keyword3ref2021.1' ] 237 | // }, 238 | // { 239 | // 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' 240 | // }, 241 | // 1 242 | // ] 243 | ``` 244 | 245 | #### Excluding Files by File Type 246 | 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. 247 | 248 | ```javascript 249 | import { Exiftool } from '@mattduffy/exiftool' 250 | let exiftool = new Exiftool() 251 | let extensionsArray = img.getExtensionsToExclude() 252 | extensionsArray.push( 'ESLINT' ) 253 | img.setExtensionsToExclude( extensionsArray ) 254 | exiftool = await exiftool.init( 'images/' ) 255 | let metadata = await exiftool.getMetadata() 256 | console.log( metatdata ) 257 | [ 258 | { 259 | SourceFile: 'images/IMG_1820.heic', 260 | 'File:FileName': 'IMG_1820.heic', 261 | 'Composite:GPSPosition': `48 deg 8' 49.20" N, 17 deg 5' 52.80" E` 262 | }, 263 | { 264 | SourceFile: 'images/copper.jpg', 265 | 'File:FileName': 'copper.jpg', 266 | 'EXIF:ImageDescription': 'Copper curtain fixture', 267 | 'IPTC:ObjectName': 'Tiny copper curtain rod', 268 | 'IPTC:Caption-Abstract': 'Copper curtain fixture', 269 | 'IPTC:Keywords': 'copper curtain fixture', 270 | 'Composite:GPSPosition': `48 deg 8' 49.20" N, 17 deg 5' 52.80" E` 271 | }, 272 | { 273 | SourceFile: 'images/IMG_1820.jpg', 274 | 'File:FileName': 'IMG_1820.jpg', 275 | 'Composite:GPSPosition': `48 deg 8' 49.20" N, 17 deg 5' 52.80" E` 276 | 277 | }, 278 | { 279 | 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/' 280 | }, 281 | 3 282 | ] 283 | ``` 284 | 285 | ### The exiftool.config File 286 | 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. 287 | 288 | ```javascript 289 | let exiftool = await new Exiftool().init( '/path/to/image.jpg' ) 290 | let oldConfigPath = exiftool.getConfigPath() 291 | console.log( oldConfigPath ) 292 | { 293 | value: '/path/to/the/exiftool/exiftool.config', 294 | error: null 295 | } 296 | ``` 297 | ```javascript 298 | let newConfigFile = '/path/to/new/exiftool.config' 299 | let result = await exiftool.setConfigPath( newConfigFile ) 300 | let metadata = await exiftool.getMetadata() 301 | ``` 302 | 303 | ### Shortcuts 304 | 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. 305 | 306 | ```javascript 307 | let exiftool = await new Exiftool().init( '/path/to/image.jpg' ) 308 | // Check to see if a shortcut with this name is already 309 | // present in the package provided exiftool.config file 310 | if (!await exiftool.hasShortcut( 'MyCoolShortcut' )) { 311 | // Shortcut was not found, save the shortcut definition to the exiftool.config file 312 | let result = await exiftool.addShortcut( "MyCoolShortcut => ['EXIF:LensInfo', 'EXIF:FocalLength', 'File:ImageWidth', 'File:ImageHeight']" ) 313 | console.log( result ) 314 | } 315 | ``` 316 | To change the default shortcut (BasicShortcut) to something else, that has already been added to the ```exiftool.config``` file, use the ```setShortcut()``` method. 317 | 318 | ```javascript 319 | let exiftool = await new Exiftool().init( '/path/to/image.jpg' ) 320 | let result = exiftool.setShortcut( 'MyCoolShortcut' ) 321 | let metadata = await exiftool.getMetadata() 322 | 323 | // Alternatively, pass the shortcut name as a parameter in the getMetadata() method 324 | let metadata = await exiftool.getMetadata( '', 'MyCoolShortcut', '' ) 325 | ``` 326 | 327 | To remove a shortcut from the package provided ```exiftool.config``` file use the ```removeShortcut()``` method. This is an **Async/Await** method. 328 | 329 | ```javascript 330 | let exiftool = await new Exiftool().init( '/path/to/image.jpg' ) 331 | // Check to see if a shortcut with this name is already 332 | // present in the package provided exiftool.config file 333 | if (await exiftool.hasShortcut( 'MyCoolShortcut' )) { 334 | // Shortcut was found, now remove it 335 | let result = await exiftool.removeShortcut( 'MyCoolShortcut' ) 336 | console.log( result ) 337 | } 338 | ``` 339 | The ```exiftool.config``` file generated by ```Exiftool``` includes a few useful shortcuts: 340 | - BasicShortcut 341 | - Location 342 | - StripGPS 343 | 344 | 345 | Exiftool creates a backup of the ```exiftool.config``` file each time it is modified by the ```addShortcut()``` or ```removeShortcut()``` methods. 346 | 347 | ### Writing Metadata to a Tag 348 | ```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). 349 | 350 | 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()```. 351 | 352 | This is an **Async/Await** method. 353 | 354 | ```javascript 355 | let exiftool = await new Exiftool().init( '/path/to/image.jpg' ) 356 | let tagToWrite = '-IPTC:Headline="Wow, Great Photo!"' 357 | let result1 = await exiftool.writeMetadataToTag( tagToWrite ) 358 | console.log(result1) 359 | //{ 360 | // value: true, 361 | // error: null, 362 | // command: '/usr/local/bin/exiftool -IPTC:Headline="Wow, Great Photo!" /path/to/image.jpg', 363 | // stdout: '1 image files updated' 364 | //} 365 | ``` 366 | Multiple tags can be written to at once by passing an array to ```writeMetadataToTag()```. 367 | 368 | ```javascript 369 | let exiftool = await new Exiftool().init( '/path/to/image.jpg' ) 370 | let tagArray = ['-IPTC:Contact="Photo McCameraguy"', '-IPTC:Keywords+=News', '-IPTC:Keywords+=Action'] 371 | let result2 = await exiftool.writeMetadataToTag( tagArray ) 372 | console.log(result2) 373 | //{ 374 | // value: true, 375 | // error: null, 376 | // command: '/usr/local/bin/exiftool -IPTC:Contact="Photo McCameraguy" -IPTC:Keywords+=News -IPTC:Keywords+=Action /path/to/image.jpg', 377 | // stdout: '1 image files updated' 378 | //} 379 | ``` 380 | 381 | #### Setting a Location 382 | 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. 383 | 384 | ```javascript 385 | let exiftool = await new Exiftool().init('/path/to/image.jpg') 386 | const coordinates = { 387 | latitude: 40.748193, 388 | longitude: -73.985062, 389 | city: 'New York City', // optional 390 | state: 'New York', // optional 391 | country: 'United States', // optional 392 | countryCode: 'USA', // optional 393 | location: 'Empire State Building', // optional 394 | } 395 | const result = await exiftool.setLocation(coordinates) 396 | ``` 397 | 398 | ### Clearing Metadata From a Tag 399 | 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. 400 | 401 | ```javascript 402 | let exiftool = await new Exiftool().init('/path/to/image.jpg') 403 | let tagToClear = '-IPTC:Contact^=' 404 | let result = await exiftool.clearMetadataFromTag(tagToClear) 405 | console.log(result) 406 | ``` 407 | 408 | ### Stripping Metadata From an Image 409 | 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. 410 | 411 | ```javascript 412 | let exiftool = await new Exiftool().init( '/path/to/image.jpg' ) 413 | let result = await exiftool.stripMetadata() 414 | /* 415 | This will result in two files: 416 | - /path/to/image.jpg (contains no metadata in the file) 417 | - /path/to/image.jpg_original (contains all the original metadata) 418 | */ 419 | ``` 420 | 421 | If you would like to change the default exiftool behavior, to overwrite the original image file, call the ```setOverwriteOriginal()``` method after the ```init()``` method. 422 | 423 | ```javascript 424 | let exiftool = await new Exiftool().init('myPhoto.jpg') 425 | exiftool.setOverwriteOriginal(true) 426 | let result await exiftool.stripMetadata() 427 | /* 428 | This will result in one file: 429 | - /path/to/myPhoto.jpg (contains no metadata in the file) 430 | */ 431 | ``` 432 | 433 | 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. 434 | ```javascript 435 | let exiftool = await new Exiftool().init('/path/to/images') 436 | await exiftool.stripLocation() 437 | // { 438 | // stdout: ' 1 directories scanned\n 4 image files updated\n', 439 | // stderr: '', 440 | // exiftool_command: '/usr/local/bin/exiftool -gps:all= /path/to/images/' 441 | // } 442 | ``` 443 | 444 | ### Making Metadata Queries Directly 445 | 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. 446 | 447 | ```javascript 448 | let exiftool = new Exiftool() 449 | 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') 450 | console.log(result) 451 | ``` 452 | 453 | ### Setting File Extension for Exiftool to ignnore 454 | 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. 455 | 456 | ```javascript 457 | let exiftool = new Exiftool() 458 | console.log(exiftool.getExtensionsToExclude()) 459 | // [ 'cjs', 'css', 'html', 'js', 'json', 'md', 'mjs', 'txt' ] 460 | const extensionsToAdd = ['scss','yaml'] 461 | const extensionsToRemove = ['txt'] 462 | exiftool.setExtensionsToExclude(extensionsToAdd, extensionsToRemove) 463 | console.log(exiftool.getExtensionsToExclude()) 464 | // [ 'cjs', 'css', 'html', 'js', 'json', 'md', 'mjs', 'scss', 'yaml' ] 465 | ``` 466 | ```javascript 467 | // Just adding file extensions 468 | exiftool.setExtensionsToExclude(extensionsToAdd) 469 | 470 | // Just removing file extensions 471 | exiftool.setExtensionsToExclude(null, extensionsToRemove) 472 | ``` 473 | 474 | ### Exiftool Version and Location 475 | 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. 476 | 477 | ```javascript 478 | let exiftool = new Exiftool() 479 | console.log(await exiftool.which()) 480 | // /usr/local/bin/exiftool 481 | console.log(await exiftool.version()) 482 | // 12.46 483 | ``` 484 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module @mattduffy/exiftool 3 | * @author Matthew Duffy 4 | * @summary The Exiftool class definition file. 5 | * @file src/index.js 6 | */ 7 | 8 | import path from 'node:path' 9 | import { fileURLToPath } from 'node:url' 10 | import { stat } from 'node:fs/promises' 11 | import { promisify } from 'node:util' 12 | import { 13 | exec, 14 | spawn, 15 | } from 'node:child_process' 16 | import Debug from 'debug' 17 | import * as fxp from 'fast-xml-parser' 18 | 19 | const __filename = fileURLToPath(import.meta.url) 20 | const __dirname = path.dirname(__filename) 21 | const cmd = promisify(exec) 22 | Debug.log = console.log.bind(console) 23 | const debug = Debug('exiftool') 24 | const error = debug.extend('ERROR') 25 | 26 | /** 27 | * A class wrapping the exiftool metadata tool. 28 | * @summary A class wrapping the exiftool image metadata extraction tool. 29 | * @class Exiftool 30 | * @author Matthew Duffy 31 | */ 32 | export class Exiftool { 33 | /** 34 | * Create an instance of the exiftool wrapper. 35 | * @param { string } imagePath - String value of file path to an image file or directory 36 | * of images. 37 | * @param { Boolean } [test] - Set to true to test outcome of exiftool command not found. 38 | */ 39 | constructor(imagePath, test) { 40 | const log = debug.extend('constructor') 41 | log('constructor method entered') 42 | this._test = test ?? false 43 | this._imgDir = imagePath ?? null 44 | this._path = imagePath ?? null 45 | this._isDirectory = null 46 | this._fileStats = null 47 | this._cwd = __dirname 48 | this._exiftool_config = `"${this._cwd}/exiftool.config"` 49 | this._extensionsToExclude = ['txt', 'js', 'json', 'mjs', 'cjs', 'md', 'html', 'css'] 50 | this._executable = null 51 | this._version = null 52 | this._MAX_BUFFER_MULTIPLIER = 10 53 | this._opts = {} 54 | this._opts.exiftool_config = `-config ${this._exiftool_config}` 55 | this._opts.outputFormat = '-json' 56 | this._opts.tagList = null 57 | this._opts.shortcut = '-BasicShortcut' 58 | this._opts.includeTagFamily = '-groupNames' 59 | this._opts.compactFormat = '-s3' 60 | this._opts.quiet = '-quiet' 61 | this._opts.excludeTypes = '' 62 | this._opts.binaryFormat = '' 63 | this._opts.gpsFormat = '' 64 | this._opts.structFormat = '' 65 | this._opts.useMWG = '' 66 | this._opts.overwrite_original = '' 67 | this._command = null 68 | this.orderExcludeTypesArray() 69 | } 70 | 71 | /** 72 | * Initializes some asynchronus properties. 73 | * @summary Initializes some asynchronus class properties not done in the constructor. 74 | * @author Matthew Duffy 75 | * @async 76 | * @param { String } imagePath - A file system path to set for exiftool to process. 77 | * @return { (Exiftool|Boolean) } Returns fully initialized instance or false. 78 | */ 79 | async init(imagePath) { 80 | const log = debug.extend('init') 81 | const err = error.extend('init') 82 | log('init method entered') 83 | try { 84 | if (this._executable === null) { 85 | this._executable = await this.which() 86 | this._version = await this.version() 87 | } 88 | log('setting the command string') 89 | this.setCommand() 90 | } catch (e) { 91 | err('could not find exiftool command') 92 | // err(e) 93 | throw new Error( 94 | 'ATTENTION!!! ' 95 | + 'exiftool IS NOT INSTALLED. ' 96 | + 'You can get exiftool at https://exiftool.org/install.html', 97 | { cause: e }, 98 | ) 99 | } 100 | if ((imagePath === '' || typeof imagePath === 'undefined') && this._path === null) { 101 | err('Param: path - was undefined.') 102 | err(`Instance property: path - ${this._path}`) 103 | return false 104 | } 105 | try { 106 | await this.setPath(imagePath) 107 | } catch (e) { 108 | err(e) 109 | throw e 110 | } 111 | try { 112 | log('checking if config file exists.') 113 | if (await this.hasExiftoolConfigFile()) { 114 | log('exiftool.config file exists') 115 | } else { 116 | log('missing exiftool.config file') 117 | log('attempting to create basic exiftool.config file') 118 | const result = this.createExiftoolConfigFile() 119 | if (!result.value && result.error) { 120 | err('failed to create new exiftool.config file') 121 | throw new Error(result.error) 122 | } 123 | log('new exiftool.config file created') 124 | } 125 | } catch (e) { 126 | err('could not create exiftool.config file') 127 | err(e) 128 | } 129 | return this 130 | } 131 | 132 | /** 133 | * Run the exiftool command in node's child_process.spawn() method. 134 | * @summary Use child_process.spawn() method instead of child_process.exec(). 135 | * @author Matthew Duffy 136 | * @return { object } 137 | */ 138 | cmd() { 139 | const log = debug.extend('cmd') 140 | const err = error.extend('cmd') 141 | log(this._executable) 142 | // log(this.getOptions()) 143 | // log(this.getOptionsAsArray()) 144 | log(this._opts) 145 | log(this._path) 146 | let output = '' 147 | const args = this.getOptionsAsArray() 148 | args.push(this._path) 149 | const exiftool = spawn(this._executable, args) 150 | exiftool.stdout.on('data', (data) => { 151 | log(data) 152 | output += data 153 | }) 154 | exiftool.stderr.on('error', (data) => { 155 | err(data) 156 | output += data 157 | }) 158 | exiftool.on('close', (code) => { 159 | log(exiftool) 160 | log(`child process exited with code ${code}`) 161 | return output 162 | }) 163 | } 164 | 165 | /** 166 | * Set the maxBuffer size for stdio to support larger image files. 167 | * @summary Set the maxBuffer size for stdio to support larger image files. 168 | * @author Matthew Duffy 169 | * @param { Number } multiplier - Value to multiply the default (1024x1024) setting. 170 | * @return { undefined } 171 | */ 172 | setMaxBufferMultiplier(multiplier) { 173 | const log = debug.extend('setMaxBufferMultiplier') 174 | let _multiplier 175 | if (multiplier === Infinity || multiplier === undefined) { 176 | _multiplier = Infinity 177 | } else { 178 | _multiplier = Number.parseInt(multiplier, 10) 179 | } 180 | if (_multiplier) { 181 | const orig = (1024 * 1024) * this._MAX_BUFFER_MULTIPLIER 182 | this._MAX_BUFFER_MULTIPLIER = _multiplier 183 | const now = (1024 * 1024) * this._MAX_BUFFER_MULTIPLIER 184 | log(`setting stdio maxBuffer ${orig} to ${now}`) 185 | } 186 | } 187 | 188 | getOutputBufferSize() { 189 | return `${this._MAX_BUFFER_MULTIPLIER * (1024 * 1024)} Bytes` 190 | } 191 | 192 | /** 193 | * Set ExifTool to overwrite the original image file when writing new tag data. 194 | * @summary Set ExifTool to overwrite the original image file when writing new tag data. 195 | * @author Matthew Duffy 196 | * @param { Boolean } enabled - True/False value to enable/disable overwriting the original 197 | * image file. 198 | * @return { undefined } 199 | */ 200 | setOverwriteOriginal(enabled) { 201 | const log = debug.extend('setOverwriteOriginal') 202 | if (enabled) { 203 | log('setting -overwrite_original option') 204 | this._opts.overwrite_original = '-overwrite_original' 205 | } else { 206 | this._opts.overwrite_original = '' 207 | } 208 | } 209 | 210 | /** 211 | * Set ExifTool to extract binary tag data. 212 | * @summary Set ExifTool to extract binary tag data. 213 | * @author Matthew Duffy 214 | * @param { Boolean } enabled - True/False value to enable/disable binary tag extraction. 215 | * @return { undefined } 216 | */ 217 | enableBinaryTagOutput(enabled) { 218 | const log = debug.extend('enableBinaryTagOutput') 219 | if (enabled) { 220 | log('Enabling binary output.') 221 | this._opts.binaryFormat = '-binary' 222 | } else { 223 | log('Disabling binary output.') 224 | this._opts.binaryFormat = '' 225 | } 226 | this.setCommand() 227 | } 228 | 229 | /** 230 | * Set ExifTool output format. 231 | * @summary Set Exiftool output format. 232 | * @author Matthew Duffy 233 | * @param { String } [fmt='json'] - Output format to set, default is JSON, but can be XML. 234 | * @return { Boolean } - Return True if new format is set, False otherwise. 235 | */ 236 | setOutputFormat(fmt = 'json') { 237 | const log = debug.extend('setOutputFormat') 238 | const err = error.extend('setOutputFormat') 239 | let newFormat 240 | const match = fmt.match(/(?xml|json)/i) 241 | if (match || match.groups?.format) { 242 | newFormat = (match.groups.format === 'xml') ? '-xmlFormat' : '-json' 243 | this._opts.outputFormat = newFormat 244 | log(`Output format is set to ${this._opts.outputFormat}`) 245 | this.setCommand() 246 | return true 247 | } 248 | err(`Output format ${fmt} not supported.`) 249 | return false 250 | } 251 | 252 | /** 253 | * Set ExifTool output formatting for GPS coordinate data. 254 | * @summary Set ExifTool output formatting for GPS coordinate data. 255 | * @author Matthew Duffy 256 | * @param { String } [fmt=default] - Printf format string with specifiers for degrees, 257 | * minutes and seconds. 258 | * @see {@link https://exiftool.org/exiftool_pod.html#c-FMT--coordFormat} 259 | * @return { undefined } 260 | */ 261 | setGPSCoordinatesOutputFormat(fmt = 'default') { 262 | const log = debug.extend('setGPSCoordinatesOutputFormat') 263 | const groups = fmt.match(/(?\+)?(?gps)/i)?.groups 264 | if (fmt.toLowerCase() === 'default') { 265 | // revert to default formatting 266 | this._opts.coordFormat = '' 267 | } else if (groups?.gps === 'gps') { 268 | this._opts.coordFormat = `-coordFormat %${(groups?.signed ? '+' : '')}.6f` 269 | } else { 270 | this._opts.coordFormat = `-coordFormat ${fmt}` 271 | } 272 | log(`GPS format is now ${fmt}`) 273 | } 274 | 275 | /** 276 | * Set ExifTool to extract xmp struct tag data. 277 | * @summary Set ExifTool to extract xmp struct tag data. 278 | * @author Matthew Duffy 279 | * @param { Boolean } enabled - True/False value to enable/disable xmp struct tag extraction. 280 | * @return { undefined } 281 | */ 282 | enableXMPStructTagOutput(enabled) { 283 | const log = debug.extend('enableXMPStructTagOutput') 284 | if (enabled) { 285 | log('Enabling XMP struct output format.') 286 | this._opts.structFormat = '-struct' 287 | } else { 288 | log('Disabling XMP struct output format.') 289 | this._opts.structFormat = '' 290 | } 291 | } 292 | 293 | /** 294 | * Tell exiftool to use the Metadata Working Group (MWG) module for overlapping EXIF, IPTC, 295 | * and XMP tqgs. 296 | * @summary Tell exiftool to use the MWG module for overlapping tag groups. 297 | * @author Matthew Duffy 298 | * @param { Boolean } - True/false value to enable/disable mwg module. 299 | * @return { undefined } 300 | */ 301 | useMWG(enabled) { 302 | const log = debug.extend('useMWG') 303 | if (enabled) { 304 | log('Enabling MWG.') 305 | this._opts.useMWG = '-use MWG' 306 | } else { 307 | log('Disabling MWG.') 308 | this._opts.useMGW = '' 309 | } 310 | } 311 | 312 | /** 313 | * Set the path for image file or directory of images to process with exiftool. 314 | * @summary Set the path of image file or directory of images to process with exiftool. 315 | * @author Matthew Duffy 316 | * @async 317 | * @param { String } imagePath - A file system path to set for exiftool to process. 318 | * @return { Object } Returns an object literal with success or error messages. 319 | */ 320 | async setPath(imagePath) { 321 | const log = debug.extend('setPath') 322 | const err = error.extend('setPath') 323 | log('setPath method entered') 324 | const o = { value: null, error: null } 325 | if (typeof imagePath === 'undefined' || imagePath === null) { 326 | o.error = 'A path to image or directory is required.' 327 | err(o.error) 328 | return o 329 | } 330 | let pathToImage 331 | if (Array.isArray(imagePath)) { 332 | let temp = imagePath.map((i) => `"${path.resolve('.', i)}"`) 333 | temp = temp.join(' ') 334 | log( 335 | 'imagePath passed as an Array. Resolving and concatting the paths into a single ' 336 | + `string: ${temp}`, 337 | ) 338 | pathToImage = temp 339 | } else { 340 | pathToImage = `"${path.resolve('.', imagePath)}"` 341 | } 342 | if (!/^(")?\//.test(pathToImage)) { 343 | // the path parameter must be a fully qualified file path, starting with / 344 | throw new Error( 345 | 'The file system path to image must be a fully qualified path, starting from root /.', 346 | ) 347 | } 348 | try { 349 | this._path = pathToImage 350 | if (/^"/.test(pathToImage)) { 351 | this._fileStats = await stat(pathToImage.slice(1, -1)) 352 | } else { 353 | this._fileStats = await stat(pathToImage) 354 | } 355 | this._isDirectory = this._fileStats.isDirectory() 356 | if (this._fileStats.isDirectory()) { 357 | this._imgDir = pathToImage 358 | } 359 | this.setCommand() 360 | o.value = true 361 | } catch (e) { 362 | err(e) 363 | o.error = e.message 364 | o.errorCode = e.code 365 | o.errorStack = e.stack 366 | } 367 | return o 368 | } 369 | 370 | /** 371 | * Get the fully qualified path to the image (or directory) specified in init. 372 | * @summary Get the full qualified path to the image. 373 | * @author Matthew Duffy 374 | * @async 375 | * @return { Object } Returns an object literal with success or error messages. 376 | */ 377 | async getPath() { 378 | const log = debug.extend('getPath') 379 | const err = error.extend('getPath') 380 | log('getPath method entered') 381 | const o = { value: null, error: null } 382 | if (this._path === null || typeof this._path === 'undefined' || this._path === '') { 383 | o.error = 'Path to an image file or image directory is not set.' 384 | err(o.error) 385 | } else { 386 | o.value = true 387 | o.file = (this._isDirectory) ? null : path.basename(this._path) 388 | o.dir = (this._isDirectory) ? this._path : path.dirname(this._path) 389 | o.path = this._path 390 | } 391 | return o 392 | } 393 | 394 | /** 395 | * Check to see if the exiftool.config file is present at the expected path. 396 | * @summary Check to see if the exiftool.config file is present at the expected path. 397 | * @author Matthew Duffy 398 | * @return { Boolean } Returns True if present, False if not. 399 | */ 400 | async hasExiftoolConfigFile() { 401 | const log = debug.extend('hasExiftoolConfigFile') 402 | const err = error.extend('hasExiftoolConfigFile') 403 | log('hasExiftoolConfigFile method entered') 404 | log('>') 405 | let exists = false 406 | const file = this._exiftool_config 407 | let stats 408 | try { 409 | log('>>') 410 | if (/^"/.test(file)) { 411 | stats = await stat(file.slice(1, -1)) 412 | } else { 413 | stats = await stat(file) 414 | } 415 | log('>>>') 416 | log(stats) 417 | exists = true 418 | } catch (e) { 419 | err('>>>>') 420 | err(e) 421 | exists = false 422 | } 423 | log('>>>>>') 424 | return exists 425 | } 426 | 427 | /** 428 | * Create the exiftool.config file if it is not present. 429 | * @summary Create the exiftool.config file if it is not present. 430 | * @author Matthew Duffy 431 | * @async 432 | * @return { Object } Returns an object literal with success or error messages. 433 | */ 434 | async createExiftoolConfigFile() { 435 | const log = debug.extend('createExiftoolConfigFile') 436 | const err = error.extend('createExiftoolConfigFile') 437 | log('createExiftoolConfigFile method entered') 438 | const o = { value: null, error: null } 439 | const stub = `%Image::ExifTool::UserDefined::Shortcuts = ( 440 | BasicShortcut => ['file:Directory','file:FileName','EXIF:CreateDate','file:MIMEType','exif:Make','exif:Model','exif:ImageDescription','iptc:ObjectName','iptc:Caption-Abstract','iptc:Keywords','Composite:GPSPosition'], 441 | Location => ['EXIF:GPSLatitudeRef', 'EXIF:GPSLatitude', 'EXIF:GPSLongitudeRef', 'EXIF:GPSLongitude', 'EXIF:GPSAltitudeRef', 442 | 'EXIF:GPSSpeedRef', 'EXIF:GPSAltitude', 'EXIF:GPSSpeed', 'EXIF:GPSImgDirectionRef', 'EXIF:GPSImgDirection', 'EXIF:GPSDestBearingRef', 'EXIF:GPSDestBearing', 443 | 'EXIF:GPSHPositioningError', 'Composite:GPSAltitude', 'Composite:GPSLatitude', 'Composite:GPSLongitude', 'Composite:GPSPosition', 'XMP:Location*', 'XMP:LocationCreatedGPSLatitude', 444 | 'XMP:LocationCreatedGPSLongitude', 'XMP:LocationShownGPSLatitude', 'XMP:LocationShownGPSLongitude'], 445 | StripGPS => ['gps:all='], 446 | );` 447 | // let fileName = `${this._cwd}/exiftool.config` 448 | const fileName = this._exiftool_config 449 | const echo = `echo "${stub}" > ${fileName}` 450 | try { 451 | log('attemtping to create exiftool.config file') 452 | const result = await cmd(echo) 453 | log(result.stdout) 454 | o.value = true 455 | } catch (e) { 456 | err('failed to create new exiftool.config file') 457 | err(e) 458 | o.error = e.message 459 | o.errorCode = e.code 460 | o.errorStack = e.stack 461 | } 462 | return o 463 | } 464 | 465 | /** 466 | * Set the GPS location to point to a new point. 467 | * @summary Set the GPS location to point to a new point. 468 | * @author Matthew Duffy 469 | * @async 470 | * @param { Object } coordinates - New GPS coordinates to assign to image. 471 | * @param { Number } coordinates.latitude - Latitude component of location. 472 | * @param { Number } coordinates.longitude - Longitude component of location. 473 | * @param { String } [coordinates.city] - City name to be assigned using MWG composite method. 474 | * @param { String } [coordinates.state] - State name to be assigned using MWG composite 475 | * method. 476 | * @param { String } [coordindates.country] - Country name to be assigned using MWG composite 477 | * method. 478 | * @param { String } [coordindates.countryCode] - Country code to be assigned using MWG 479 | * composite method. 480 | * @param { String } [coordinates.location] - Location name to be assigned using MWG composite 481 | * method. 482 | * @throws { Error } Throws an error if no image is set yet. 483 | * @return { Object } Object literal with stdout or stderr. 484 | */ 485 | async setLocation(coordinates) { 486 | const log = debug.extend('setLocation') 487 | const err = error.extend('setLocation') 488 | if (!this._path) { 489 | throw new Error('No image file set yet.') 490 | } 491 | try { 492 | const lat = parseFloat(coordinates?.latitude) ?? null 493 | const latRef = `${(lat > 0) ? 'N' : 'S'}` 494 | const lon = parseFloat(coordinates?.longitude) ?? null 495 | const lonRef = `${(lon > 0) ? 'E' : 'W'}` 496 | const alt = 10000 497 | const altRef = 0 498 | let command = `${this._executable} ` 499 | if (lat && lon) { 500 | command += `-GPSLatitude=${lat} -GPSLatitudeRef=${latRef} -GPSLongitude=${lon} ` 501 | + `-GPSLongitudeRef=${lonRef} -GPSAltitude=${alt} -GPSAltitudeRef=${altRef} ` 502 | + `-XMP:LocationShownGPSLatitude=${lat} -XMP:LocationShownGPSLongitude=${lon}` 503 | } 504 | if (coordinates?.city !== undefined) { 505 | command += ` -IPTC:City='${coordinates.city}' ` 506 | + `-XMP-iptcExt:LocationShownCity='${coordinates.city}' ` 507 | + `-XMP:City='${coordinates.city}'` 508 | // command += ` -MWG:City='${coordinates.city}'` 509 | } 510 | if (coordinates?.state !== undefined) { 511 | command += ` -IPTC:Province-State='${coordinates.state}' ` 512 | + `-XMP-iptcExt:LocationShownProvinceState='${coordinates.state}' ` 513 | + `-XMP:Country='${coordinates.state}'` 514 | // command += ` -MWG:State='${coordinates.state}'` 515 | } 516 | if (coordinates?.country !== undefined) { 517 | command += ` -IPTC:Country-PrimaryLocationName='${coordinates.country}' ` 518 | + '-XMP:LocationShownCountryName= ' 519 | + `-XMP:LocationShownCountryName='${coordinates.country}' ` 520 | + `-XMP:Country='${coordinates.country}'` 521 | // command += ` -MWG:Country='${coordinates.country}'` 522 | } 523 | if (coordinates?.countryCode !== undefined) { 524 | command += ` -IPTC:Country-PrimaryLocationCode='${coordinates.countryCode}' ` 525 | + '-XMP:LocationShownCountryCode= ' 526 | + `-XMP:LocationShownCountryCode='${coordinates.countryCode}' ` 527 | + `-XMP:CountryCode='${coordinates.countryCode}'` 528 | // command += ` -MWG:Country='${coordinates.country}'` 529 | } 530 | if (coordinates?.location !== undefined) { 531 | command += ` -IPTC:Sub-location='${coordinates.location}' ` 532 | + `-XMP-iptcExt:LocationShownSublocation='${coordinates.location}' ` 533 | + `-XMP:Location='${coordinates.location}'` 534 | // command += ` -MWG:Location='${coordinates.location}'` 535 | } 536 | command += ` -struct -codedcharacterset=utf8 ${this._path}` 537 | log(command) 538 | const result = await cmd(command) 539 | result.exiftool_command = command 540 | log('set new location: %o', result) 541 | return result 542 | } catch (e) { 543 | err(e) 544 | throw new Error(e) 545 | } 546 | } 547 | 548 | /** 549 | * Set the GPS location to point to null island. 550 | * @summary Set the GPS location to point to null island. 551 | * @author Matthew Duffy 552 | * @async 553 | * @throws { Error } Throws an error if no image is set yet. 554 | * @return { Object } Object literal with stdout or stderr. 555 | */ 556 | async nullIsland() { 557 | const log = debug.extend('nullIsland') 558 | const err = error.extend('nullIsland') 559 | if (!this._path) { 560 | throw new Error('No image file set yet.') 561 | } 562 | try { 563 | const latitude = 0.0 564 | const latRef = 'S' 565 | const longitude = 0.0 566 | const longRef = 'W' 567 | const alt = 10000 568 | const altRef = 0 569 | const command = `${this._executable} -GPSLatitude=${latitude} ` 570 | + `-GPSLatitudeRef=${latRef} -GPSLongitude=${longitude} -GPSLongitudeRef=${longRef} ` 571 | + `-GPSAltitude=${alt} -GPSAltitudeRef=${altRef} ${this._path}` 572 | const result = await cmd(command) 573 | result.exiftool_command = command 574 | log('null island: %o', result) 575 | return result 576 | } catch (e) { 577 | err(e) 578 | throw new Error(e) 579 | } 580 | } 581 | 582 | /** 583 | * Set the GPS location to point nemo. 584 | * @summary Set the GPS location to point nemo. 585 | * @author Matthew Duffy 586 | * @async 587 | * @throws { Error } Throws an error if no image is set yet. 588 | * @return { Object } Object literal with stdout or stderr. 589 | */ 590 | async nemo() { 591 | const log = debug.extend('nemo') 592 | const err = error.extend('nemo') 593 | if (!this._path) { 594 | throw new Error('No image file set yet.') 595 | } 596 | try { 597 | const latitude = 22.319469 598 | const latRef = 'S' 599 | const longitude = 114.189505 600 | const longRef = 'W' 601 | const alt = 10000 602 | const altRef = 0 603 | const command = `${this._executable} -GPSLatitude=${latitude} -GPSLatitudeRef=${latRef} ` 604 | + `-GPSLongitude=${longitude} -GPSLongitudeRef=${longRef} -GPSAltitude=${alt} ` 605 | + `-GPSAltitudeRef=${altRef} ${this._path}` 606 | const result = await cmd(command) 607 | result.exiftool_command = command 608 | log('nemo: %o', result) 609 | return result 610 | } catch (e) { 611 | err(e) 612 | throw new Error(e) 613 | } 614 | } 615 | 616 | /** 617 | * Strip all location data from the image. 618 | * @summary Strip all location data from the image. 619 | * @author Matthew Duffy 620 | * @async 621 | * @throws { Error } Throws an error if no image is set yet. 622 | * @return { Object } Object literal with stdout or stderr. 623 | */ 624 | async stripLocation() { 625 | const log = debug.extend('stripLocation') 626 | const err = error.extend('stripLocation') 627 | if (!this._path) { 628 | const msg = 'No image file set yet.' 629 | err(msg) 630 | throw new Error(msg) 631 | } 632 | try { 633 | const tags = `${this._opts.overwrite_original} -gps:all= -XMP:LocationShown*= ` 634 | + '-XMP:LocationCreated*= -XMP:Location= -XMP:City= -XMP:Country*= -IPTC:City= ' 635 | + '-IPTC:Province-State= -IPTC:Sub-location= -IPTC:Country*= ' 636 | const command = `${this._executable} ${tags} ${this._path}` 637 | const result = await cmd(command) 638 | result.exiftool_command = command 639 | log('stripLocation: %o', result) 640 | return result 641 | } catch (e) { 642 | err(e) 643 | throw new Error(e) 644 | } 645 | } 646 | 647 | /** 648 | * Find the path to the executable exiftool binary. 649 | * @summary Find the path to the executable exiftool binary. 650 | * @author Matthew Duffy 651 | * @async 652 | * @return { String|Error } Returns the file system path to exiftool binary, or throws an 653 | * error. 654 | */ 655 | async which() { 656 | const log = debug.extend('which') 657 | const err = error.extend('which') 658 | if (this._executable !== null) { 659 | return this._executable 660 | } 661 | let which 662 | try { 663 | // test command not founc condition 664 | const exiftool = (!this?._test) ? 'exiftool' : 'exitfool' 665 | // which = await cmd('which exiftool') 666 | which = await cmd(`which ${exiftool}`) 667 | if (which.stdout.slice(-1) === '\n') { 668 | which = which.stdout.slice(0, -1) 669 | this._executable = which 670 | log(`found: ${which}`) 671 | } 672 | } catch (e) { 673 | err(e) 674 | throw new Error('Exiftool not found?', { cause: e }) 675 | } 676 | return which 677 | } 678 | 679 | /** Get the version number of the currently installed exiftool. 680 | * @summary Get the version number of the currently installed exiftool. 681 | * @author Matthew Duffy 682 | * @async 683 | * @returns { String|Error } Returns the version of exiftool as a string, or throws an error. 684 | */ 685 | async version() { 686 | const log = debug.extend('version') 687 | const err = error.extend('version') 688 | if (this._version !== null) { 689 | return this._version 690 | } 691 | let ver 692 | const _exiftool = (this._executable !== null ? this._executable : await this.which()) 693 | try { 694 | ver = await cmd(`${_exiftool} -ver`) 695 | if (ver.stdout.slice(-1) === '\n') { 696 | ver = ver.stdout.slice(0, -1) 697 | this._version = ver 698 | log(`found: ${ver}`) 699 | } 700 | } catch (e) { 701 | err(e) 702 | throw new Error('Exiftool not found?', { cause: e }) 703 | } 704 | return ver 705 | } 706 | 707 | /** 708 | * Set the full command string from the options. 709 | * @summary Set the full command string from the options. 710 | * @author Matthew Duffy 711 | * @return { undefined } 712 | */ 713 | setCommand() { 714 | const log = debug.extend('setCommand') 715 | this._command = `${this._executable} ${this.getOptions()} ${this._path}` 716 | log(`exif command set: ${this._command}`) 717 | } 718 | 719 | /** 720 | * Lexically order the array of file extensions to be excluded from the exiftool query. 721 | * @summary Lexically order the array of file extensions to be excluded from the exiftool 722 | * query. 723 | * @author Matthew Duffy 724 | * @return { undefined } 725 | */ 726 | orderExcludeTypesArray() { 727 | const log = debug.extend('orderExcludeTypesArray') 728 | this._extensionsToExclude.forEach((ext) => ext.toLowerCase()) 729 | this._extensionsToExclude.sort((a, b) => { 730 | if (a.toLowerCase() < b.toLowerCase()) return -1 731 | if (a.toLowerCase() > b.toLowerCase()) return 1 732 | return 0 733 | }) 734 | log(this._extensionsToExclude) 735 | // this._extensionsToExclude = temp 736 | } 737 | 738 | /** 739 | * Compose the command line string of file type extentions for exiftool to exclude. 740 | * @summary Compose the command line string of file type extensions for exiftool to exclude. 741 | * @author Matthew Duffy 742 | * @return { undefined } 743 | */ 744 | setExcludeTypes() { 745 | const log = debug.extend('setExcludeTypes') 746 | this._extensionsToExclude.forEach((ext) => { this._opts.excludeTypes += `--ext ${ext} ` }) 747 | log(this._extensionsToExclude) 748 | } 749 | 750 | /** 751 | * Get the instance property array of file type extentions for exiftool to exclude. 752 | * @summary Get the instance property array of file type extensions for exiftool to exclude. 753 | * @author Matthew Duffy 754 | * @returns { String[] } The array of file type extentions for exiftool to exclude. 755 | */ 756 | getExtensionsToExclude() { 757 | return this._extensionsToExclude 758 | } 759 | 760 | /** 761 | * Set the array of file type extentions that exiftool should ignore while recursing through 762 | * a directory. 763 | * @summary Set the array of file type extenstions that exiftool should ignore while 764 | * recursing through a directory. 765 | * @author Matthew Duffy 766 | * @throws Will throw an error if extensionsArray is not an Array. 767 | * @param { String[] } extensionsToAddArray - An array of file type extensions to add to the 768 | * exclude list. 769 | * @param { String[] } extensionsToRemoveArray - An array of file type extensions to remove 770 | * from the exclude list. 771 | * @return { undefined } 772 | */ 773 | setExtensionsToExclude(extensionsToAddArray = null, extensionsToRemoveArray = null) { 774 | const log = debug.extend('setExtensiosToExclude') 775 | // if (extensionsToAddArray !== '' || extensionsToAddArray !== null) { 776 | if (extensionsToAddArray !== null) { 777 | if (extensionsToAddArray.constructor !== Array) { 778 | throw new Error('Expecting an array of file extensions to be added.') 779 | } 780 | extensionsToAddArray.forEach((ext) => { 781 | if (!this._extensionsToExclude.includes(ext.toLowerCase())) { 782 | this._extensionsToExclude.push(ext.toLowerCase()) 783 | } 784 | }) 785 | } 786 | // if (extensionsToRemoveArray !== '' || extensionsToRemoveArray !== null) { 787 | if (extensionsToRemoveArray !== null) { 788 | if (extensionsToRemoveArray.constructor !== Array) { 789 | throw new Error('Expecting an array of file extensions to be removed.') 790 | } 791 | extensionsToRemoveArray.forEach((ext) => { 792 | const index = this._extensionsToExclude.indexOf(ext.toLowerCase()) 793 | if (index > 0) { 794 | this._extensionsToExclude.splice(index, 1) 795 | } 796 | }) 797 | } 798 | this.orderExcludeTypesArray() 799 | this._opts.excludeTypes = '' 800 | this.setExcludeTypes() 801 | log(this._opts.excludeTypes) 802 | } 803 | 804 | /** 805 | * Concatenate all the exiftool options together into a single string. 806 | * @summary Concatenate all the exiftool options together into a single string. 807 | * @author Matthew Duffy 808 | * @return { String } Commandline options to exiftool. 809 | */ 810 | getOptions() { 811 | const log = debug.extend('getOptions') 812 | let tmp = '' 813 | if (this._opts.excludeTypes === '') { 814 | this.setExcludeTypes() 815 | } 816 | Object.keys(this._opts).forEach((key) => { 817 | if (/overwrite_original/i.test(key)) { 818 | log(`ignoring ${key}`) 819 | log('well, not really for now.') 820 | tmp += `${this._opts[key]} ` 821 | } else if (/tagList/i.test(key) && this._opts.tagList === null) { 822 | // log(`ignoring ${key}`) 823 | tmp += '' 824 | } else { 825 | tmp += `${this._opts[key]} ` 826 | } 827 | }) 828 | log('options string: ', tmp) 829 | return tmp 830 | } 831 | 832 | /** 833 | * Concatenate all the exiftool options together into a String[]. 834 | * @summary Concatenate all the exiftool options together into a String[]. 835 | * @author Matthew Duffy 836 | * @return { String[] } Array of commandline options to exiftool. 837 | */ 838 | getOptionsAsArray() { 839 | const log = debug.extend('getOptionsAsArray') 840 | const tmp = [] 841 | if (this._opts.excludeTypes === '') { 842 | this.setExcludeTypes() 843 | } 844 | Object.keys(this._opts).forEach((key) => { 845 | if (/overwrite_original/i.test(key)) { 846 | tmp.push(this._opts[key]) 847 | } else if (/tagList/i.test(key) && this._opts.tagList === null) { 848 | log(`ignoring ${key}`) 849 | } else if (this._opts[key] === '') { 850 | log(`ignoring empty ${key}`) 851 | } else { 852 | tmp.push(this._opts[key]) 853 | } 854 | }) 855 | log('options array: ', tmp) 856 | return tmp 857 | } 858 | 859 | /** 860 | * Set the file system path to a different exiftool.config to be used. 861 | * @summary Set the file system path to a different exiftool.config to be used. 862 | * @author Matthew Duffy 863 | * @async 864 | * @param { String } newConfigPath - A string containing the file system path to a valid 865 | * exiftool.config file. 866 | * @return { Object } Returns an object literal with success or error messages. 867 | */ 868 | async setConfigPath(newConfigPath) { 869 | const log = debug.extend('setConfigPath') 870 | const o = { value: null, error: null } 871 | if (newConfigPath === '' || newConfigPath === null) { 872 | o.error = 'A valid file system path to an exiftool.config file is required.' 873 | } else { 874 | try { 875 | // const stats = await stat(newConfigPath) 876 | if (/^"/.test(newConfigPath)) { 877 | await stat(newConfigPath.slice(1, -1)) 878 | this._exiftool_config = newConfigPath 879 | } else { 880 | await stat(newConfigPath) 881 | this._exiftool_config = `"${newConfigPath}"` 882 | } 883 | o.value = true 884 | this._opts.exiftool_config = `-config ${this._exiftool_config}` 885 | this.setCommand() 886 | } catch (e) { 887 | o.value = false 888 | o.error = e.message 889 | o.e = e 890 | } 891 | } 892 | log(`Config path set to: ${this._exiftool_config}`) 893 | return o 894 | } 895 | 896 | /** 897 | * Get the instance property for the file system path to the exiftool.config file. 898 | * @summary Get the instance property for the file system path to the exiftool.config file. 899 | * @author Matthew Duffy 900 | * @returns { Object } Returns an object literal with success or error messages. 901 | */ 902 | getConfigPath() { 903 | const log = debug.extend('getConfigPath') 904 | log('getConfigPath method entered') 905 | const o = { value: null, error: null } 906 | if (this._exiftool_config === '' 907 | || this._exiftool_config === null 908 | || typeof this._exiftool_config === 'undefined') { 909 | o.error = 'No path set for the exiftool.config file.' 910 | } else if (/^"/.test(this._exiftool_config)) { 911 | o.value = this._exiftool_config.slice(1, -1) 912 | } else { 913 | o.value = this._exiftool_config 914 | } 915 | return o 916 | } 917 | 918 | /** 919 | * Check the exiftool.config to see if the specified shortcut exists. 920 | * @summary Check to see if a shortcut exists. 921 | * @author Matthew Duffy 922 | * @param { String } shortcut - The name of a shortcut to check if it exists in the 923 | * exiftool.config. 924 | * @return { Boolean } Returns true if the shortcut exists in the exiftool.config, false if 925 | * not. 926 | */ 927 | async hasShortcut(shortcut) { 928 | const log = debug.extend('hasShortcut') 929 | const err = error.extend('hasShortcut') 930 | let exists 931 | if (shortcut === 'undefined' || shortcut === null) { 932 | exists = false 933 | } else { 934 | try { 935 | const re = new RegExp(`${shortcut}`, 'i') 936 | const grep = `grep -i "${shortcut}" ${this._exiftool_config}` 937 | const output = await cmd(grep) 938 | output.grep_command = grep 939 | log('grep -i: %o', output) 940 | const stdout = output.stdout?.match(re) 941 | if (shortcut.toLowerCase() === stdout[0].toLowerCase()) { 942 | exists = true 943 | } else { 944 | exists = false 945 | } 946 | } catch (e) { 947 | err(e) 948 | exists = false 949 | } 950 | } 951 | return exists 952 | } 953 | 954 | /** 955 | * Add a new shortcut to the exiftool.config file. 956 | * @summary Add a new shortcut to the exiftool.config file. 957 | * @author Matthew Duffy 958 | * @async 959 | * @param { String } newShortcut - The string of text representing the new shortcut to add 960 | * to exiftool.config file. 961 | * @return { Object } Returns an object literal with success or error messages. 962 | */ 963 | async addShortcut(newShortcut) { 964 | const log = debug.extend('addShortcut') 965 | const err = error.extend('addShortcut') 966 | const o = { value: null, error: null } 967 | if (newShortcut === 'undefined' || newShortcut === '') { 968 | o.error = 'Shortcut name must be provided as a string.' 969 | } else { 970 | try { 971 | let sedCommand 972 | if (process.platform === 'darwin') { 973 | /* eslint-disable-next-line no-useless-escape */ 974 | sedCommand = `sed -i'.bk' -e '2i\\ 975 | ${newShortcut},' ${this._exiftool_config}` 976 | } else { 977 | sedCommand = `sed -i.bk "2i\\ ${newShortcut}," ${this._exiftool_config}` 978 | } 979 | log(`sed command: ${sedCommand}`) 980 | const output = await cmd(sedCommand) 981 | log(output) 982 | o.command = sedCommand 983 | if (output.stderr === '') { 984 | o.value = true 985 | } else { 986 | o.value = false 987 | o.error = output.stderr 988 | } 989 | } catch (e) { 990 | err(`Failed to add shortcut, ${newShortcut}, to exiftool.config file`) 991 | err(e) 992 | } 993 | } 994 | return o 995 | } 996 | 997 | /** 998 | * Remove a shorcut from the exiftool.config file. 999 | * @summary Remove a shortcut from the exiftool.config file. 1000 | * @author Matthew Duffy 1001 | * @async 1002 | * @param { String } shortcut - A string containing the name of the shortcut to remove. 1003 | * @return { Object } Returns an object literal with success or error messages. 1004 | */ 1005 | async removeShortcut(shortcut) { 1006 | const log = debug.extend('removeShortcut') 1007 | const err = error.extend('removeShortcut') 1008 | const o = { value: null, error: null } 1009 | if (shortcut === 'undefined' || shortcut === '') { 1010 | o.error = 'Shortcut name must be provided as a string.' 1011 | } else { 1012 | try { 1013 | const sedCommand = `sed -i.bk "/${shortcut}/d" ${this._exiftool_config}` 1014 | o.command = sedCommand 1015 | log(`sed command: ${sedCommand}`) 1016 | const output = await cmd(sedCommand) 1017 | log(output) 1018 | if (output.stderr === '') { 1019 | o.value = true 1020 | } else { 1021 | o.value = false 1022 | o.error = output.stderr 1023 | } 1024 | } catch (e) { 1025 | err(`Failed to remove shortcut, ${shortcut}, from the exiftool.config file.`) 1026 | err(e) 1027 | } 1028 | } 1029 | return o 1030 | } 1031 | 1032 | /** 1033 | * Clear the currently set exiftool shortcut. No shortcut means exiftool returns all tags. 1034 | * @summary Clear the currently set exiftool shortcut. 1035 | * @author Matthew Duffy 1036 | * @return { undefined } 1037 | */ 1038 | clearShortcut() { 1039 | const log = debug.extend('clearShortcut') 1040 | this._opts.shortcut = '' 1041 | this.setCommand() 1042 | log('Shortcut option cleared.') 1043 | } 1044 | 1045 | /** 1046 | * Set a specific exiftool shortcut. The new shortcut must already exist in the 1047 | * exiftool.config file. 1048 | * @summary Set a specific exiftool shortcut to use. 1049 | * @author Matthew Duffy 1050 | * @param { String } shortcut - The name of a new exiftool shortcut to use. 1051 | * @return { Object } Returns an object literal with success or error messages. 1052 | */ 1053 | setShortcut(shortcut) { 1054 | const log = debug.extend('setShortcut') 1055 | const err = error.extend('setShortcut') 1056 | const o = { value: null, error: null } 1057 | if (shortcut === undefined || shortcut === null) { 1058 | o.error = 'Shortcut must be a string value.' 1059 | err(o.error) 1060 | } else { 1061 | this._opts.shortcut = `-${shortcut}` 1062 | this.setCommand() 1063 | o.value = true 1064 | log(`Shortcut set to: ${this._opts.shortcut}`) 1065 | } 1066 | return o 1067 | } 1068 | 1069 | /** 1070 | * Set one or more explicit metadata tags in the command string for exiftool to extract. 1071 | * @summary Set one or more explicit metadata tags in the command string for exiftool to 1072 | * extract. 1073 | * @author Matthew Duffy 1074 | * @param { String|String[]} tagsToExtract - A string or an array of metadata tags to be 1075 | * passed to exiftool. 1076 | * @return { Object } Returns an object literal with success or error messages. 1077 | */ 1078 | setMetadataTags(tagsToExtract) { 1079 | const log = debug.extend('setMetadataTags') 1080 | const err = error.extend('setMetadataTags') 1081 | let tags 1082 | log(`>> ${tagsToExtract}`) 1083 | const o = { value: null, error: null } 1084 | if (tagsToExtract === 'undefined' || tagsToExtract === '' || tagsToExtract === null) { 1085 | o.error = 'One or more metadata tags are required' 1086 | err(o.error) 1087 | } else { 1088 | if (Array === tagsToExtract.constructor) { 1089 | log('array of tags') 1090 | // check array elements so they all have '-' prefix 1091 | tags = tagsToExtract.map((tag) => { 1092 | if (!/^-{1,1}[^-]?.+$/.test(tag)) { 1093 | return `-${tag}` 1094 | } 1095 | return tag 1096 | }) 1097 | log(tags) 1098 | // join array elements in to a string 1099 | this._opts.tagList = `${tags.join(' ')}` 1100 | } 1101 | if (String === tagsToExtract.constructor) { 1102 | log('string of tags') 1103 | if (tagsToExtract.match(/^-/) === null) { 1104 | this._opts.tagList = `-${tagsToExtract}` 1105 | } 1106 | this._opts.tagList = tagsToExtract 1107 | } 1108 | log(this._opts.tagList) 1109 | log(this._command) 1110 | this.setCommand() 1111 | o.value = true 1112 | } 1113 | return o 1114 | } 1115 | 1116 | /** 1117 | * Run the composed exiftool command to get the requested exif metadata. 1118 | * @summary Get the exif metadata for one or more image files. 1119 | * @author Matthew Duffy 1120 | * @async 1121 | * @throws { Error } Throw an error if -all= tag is included in the tagsToExtract parameter. 1122 | * @throws { Error } Throw an error if exiftool returns a fatal error via stderr. 1123 | * @param { String } [ fileOrDir=null ] - The string path to a file or directory for 1124 | * exiftool to use. 1125 | * @param { String } [ shortcut=''] - A string containing the name of an existing shortcut 1126 | * for exiftool to use. 1127 | * @param { String } [ tagsToExtract=null ] - A string of one or more metadata tags to pass 1128 | * to exiftool. 1129 | * @return { (Object|Error) } JSON object literal of metadata or throws an Error if failed. 1130 | */ 1131 | async getMetadata(fileOrDir = null, shortcut = '', ...tagsToExtract) { 1132 | const log = debug.extend('getMetadata') 1133 | const err = error.extend('getMetadata') 1134 | if (fileOrDir !== null && fileOrDir !== '') { 1135 | await this.setPath(fileOrDir) 1136 | } 1137 | log(`shortcut: ${shortcut}`) 1138 | // if (shortcut !== null && shortcut !== '') { 1139 | if (shortcut !== null && shortcut !== '' && shortcut !== false) { 1140 | this.setShortcut(shortcut) 1141 | } else if (shortcut === null || shortcut === false) { 1142 | this.clearShortcut() 1143 | } else { 1144 | // leave default BasicShortcut in place 1145 | // this.clearShortcut() 1146 | log(`leaving any currenly set shortcut in place: ${this._opts.shortcut}`) 1147 | } 1148 | if (tagsToExtract.length > 0) { 1149 | if (tagsToExtract.includes('-all= ')) { 1150 | err("Can't include metadata stripping -all= tag in get metadata request.") 1151 | throw new Error("Can't include metadata stripping -all= tag in get metadata reqeust.") 1152 | } 1153 | const options = this.setMetadataTags(tagsToExtract.flat()) 1154 | log(options) 1155 | log(this._opts) 1156 | if (options.error) { 1157 | err(options.error) 1158 | throw new Error('tag list option failed') 1159 | } 1160 | } 1161 | log(this._command) 1162 | try { 1163 | let count 1164 | // Increase the stdio buffer size because some images have almost as much 1165 | // metadata stuffed insided as image data itself. This sets stdio output 1166 | // buffer sizee to 10MB. 1167 | let metadata = await cmd(this._command, { 1168 | // maxBuffer: (1024 * 1204) * this._MAX_BUFFER_MULTIPLIER, 1169 | maxBuffer: Infinity, 1170 | }) 1171 | if (metadata.stderr !== '') { 1172 | throw new Error(metadata.stderr) 1173 | } 1174 | const match = this._opts.outputFormat.match(/(?xml.*|json)/i) 1175 | if (match && match.groups.format === 'json') { 1176 | metadata = JSON.parse(metadata.stdout) 1177 | count = metadata.length 1178 | metadata.push({ exiftool_command: this._command }) 1179 | metadata.push({ format: 'json' }) 1180 | metadata.push(count) 1181 | } else if (match && match.groups.format === 'xmlFormat') { 1182 | const tmp = [] 1183 | const parser = new fxp.XMLParser() 1184 | const xml = parser.parse(metadata.stdout) 1185 | log(xml) 1186 | tmp.push(xml) 1187 | tmp.push({ raw: metadata.stdout }) 1188 | tmp.push({ format: 'xml' }) 1189 | tmp.push({ exiftool_command: this._command }) 1190 | tmp.push(count) 1191 | metadata = tmp 1192 | } else { 1193 | metadata = metadata.stdout 1194 | } 1195 | log(metadata) 1196 | return metadata 1197 | } catch (e) { 1198 | err(e) 1199 | e.exiftool_command = this._command 1200 | return e 1201 | } 1202 | } 1203 | 1204 | async getThumbnail(image) { 1205 | return this.getThumbnails(image) 1206 | } 1207 | 1208 | /** 1209 | * Extract any embedded thumbnail/preview images. 1210 | * @summary Extract any embedded thumbnail/preview images. 1211 | * @author Matthew Duffy 1212 | * @async 1213 | * @param { String } [image] - The name of the image to get thumbnails from. 1214 | * @throws { Error } Throws an error if getting thumbnail data fails for any reason. 1215 | * @return { Object } Collection of zero or more thumbnails from image. 1216 | */ 1217 | async getThumbnails(image) { 1218 | const log = debug.extend('getThumbnails') 1219 | const err = error.extend('getThumbnails') 1220 | if (image) { 1221 | await this.setPath(image) 1222 | } 1223 | if (this._path === null) { 1224 | const msg = 'No image was specified to write new metadata content to.' 1225 | err(msg) 1226 | throw new Error() 1227 | } 1228 | this.setOutputFormat() 1229 | this.clearShortcut() 1230 | this.enableBinaryTagOutput(true) 1231 | this.setMetadataTags('-Preview:all') 1232 | log(this._command) 1233 | let metadata 1234 | try { 1235 | metadata = await cmd(this._command) 1236 | if (metadata.stderr !== '') { 1237 | err(metadata.stderr) 1238 | throw new Error(metadata.stderr) 1239 | } 1240 | metadata = JSON.parse(metadata.stdout) 1241 | metadata.push({ exiftool_command: this._command }) 1242 | metadata.push({ format: 'json' }) 1243 | } catch (e) { 1244 | err(e) 1245 | e.exiftool_command = this._command 1246 | return e 1247 | } 1248 | // log(metadata) 1249 | return metadata 1250 | } 1251 | 1252 | /** 1253 | * Embed the given thumbnail data into the image. Optionally provide a specific metadata 1254 | * tag target. 1255 | * @summary Embed the given thumbnail data into the image. Optionally provide a specific 1256 | * metadata tag target. 1257 | * @author Matthew Duffy 1258 | * @async 1259 | * @param { String } data - A resolved path to the thumbnail data. 1260 | * @param { String } [image = null] - The target image to receive the thumbnail data. 1261 | * @param { String } [tag = 'EXIF:ThumbnailImage'] - Optional destination tag, if other than 1262 | * the default value. 1263 | * @throws { Error } Throws an error if saving thumbnail data fails for any reason. 1264 | * @return { Object } An object containing success or error messages, plus the exiftool 1265 | * command used. 1266 | */ 1267 | async setThumbnail(data, image = null, tag = 'EXIF:ThumbnailImage') { 1268 | const log = debug.extend('setThumbnail') 1269 | const err = error.extend('setThumbnail') 1270 | if (!data) { 1271 | const msg = 'Missing required data parameter.' 1272 | err(msg) 1273 | throw new Error(msg) 1274 | } 1275 | if (image) { 1276 | await this.setPath(image) 1277 | } 1278 | const dataPath = path.resolve(data) 1279 | // this.setOverwriteOriginal(true) 1280 | this.setOutputFormat() 1281 | this.clearShortcut() 1282 | this.setMetadataTags(`"-${tag}<=${dataPath}"`) 1283 | log(this._command) 1284 | let result 1285 | try { 1286 | result = await cmd(this._command) 1287 | result.exiftool_command = this._command 1288 | result.success = true 1289 | } catch (e) { 1290 | err(e) 1291 | e.exiftool_command = this._command 1292 | } 1293 | log(result) 1294 | return result 1295 | } 1296 | 1297 | /** 1298 | * Extract the raw XMP data as xmp-rdf packet. 1299 | * @summary Extract the raw XMP data as xmp-rdf packet. 1300 | * @author Matthew Duffy 1301 | * @async 1302 | * @throws { Error } Throw an error if exiftool returns a fatal error via stderr. 1303 | * @return { (Object|Error) } JSON object literal of metadata or throws an Error if failed. 1304 | */ 1305 | async getXmpPacket() { 1306 | const log = debug.extend('getXmpPacket') 1307 | const err = error.extend('getXmpPacket') 1308 | let packet 1309 | try { 1310 | const command = `${this._executable} ${this._opts.exiftool_config} -xmp -b ${this._path}` 1311 | packet = await cmd(command) 1312 | if (packet.stderr !== '') { 1313 | err(packet.stderr) 1314 | throw new Error(packet.stderr) 1315 | } 1316 | packet.exiftool_command = command 1317 | // const parser = new fxp.XMLParser() 1318 | // const builder = new fxp.XMLBuilder() 1319 | // packet.xmp = builder.build(parser.parse(packet.stdout)) 1320 | packet.xmp = packet.stdout 1321 | delete packet.stdout 1322 | delete packet.stderr 1323 | } catch (e) { 1324 | err(e) 1325 | e.exiftool_command = this._command 1326 | return e 1327 | } 1328 | log(packet) 1329 | return packet 1330 | } 1331 | 1332 | /** 1333 | * Write a new metadata value to the designated tags. 1334 | * @summary Write a new metadata value to the designated tags. 1335 | * @author Matthew Duffy 1336 | * @async 1337 | * @param { String|String[] } metadataToWrite - A string value with tag name and new value 1338 | * or an array of tag strings. 1339 | * @throws { Error } Throws error if there is no valid path to an image file. 1340 | * @throws { Error } Thros error if the current path is to a directory instead of a file. 1341 | * @throws { Error } Thros error if the expected parameter is missing or of the wrong type. 1342 | * @throws { Error } Thros error if exiftool returns a fatal error via stderr. 1343 | * @return { Object|Error } Returns an object literal with success or error messages, or 1344 | * throws an exception if no image given. 1345 | */ 1346 | async writeMetadataToTag(metadataToWrite) { 1347 | const log = debug.extend('writeMetadataToTag') 1348 | const err = error.extend('writeMetadataToTag') 1349 | const o = { value: null, error: null } 1350 | let tagString = '' 1351 | if (this._path === null) { 1352 | const msg = 'No image was specified to write new metadata content to.' 1353 | err(msg) 1354 | throw new Error() 1355 | } 1356 | if (this._isDirectory) { 1357 | const msg = 'A directory was given. Use a path to a specific file instead.' 1358 | err(msg) 1359 | throw new Error(msg) 1360 | } 1361 | switch (metadataToWrite.constructor) { 1362 | case Array: 1363 | tagString = metadataToWrite.join(' ') 1364 | break 1365 | case String: 1366 | tagString = metadataToWrite 1367 | break 1368 | default: 1369 | throw new Error( 1370 | 'Expected a string or an array of strings. ' 1371 | + `Received: ${metadataToWrite.constructor}`, 1372 | ) 1373 | } 1374 | try { 1375 | log(`tagString: ${tagString}`) 1376 | const file = `${this._path}` 1377 | // const write = `${this._executable} ${this._opts.exiftool_config} ${tagString} ${file}` 1378 | const write = `${this._executable} ` 1379 | + `${this._opts.exiftool_config} ` 1380 | + `${this._opts.overwrite_original} ` 1381 | + `${tagString} ${file}` 1382 | o.command = write 1383 | const result = await cmd(write) 1384 | if (result.stdout.trim() === null) { 1385 | throw new Error(`Failed to write new metadata to image - ${file}`) 1386 | } 1387 | o.value = true 1388 | o.stdout = result.stdout.trim() 1389 | } catch (e) { 1390 | err(e) 1391 | o.error = e 1392 | } 1393 | return o 1394 | } 1395 | 1396 | /** 1397 | * Clear the metadata from a tag, but keep the tag rather than stripping it from the image 1398 | * file. 1399 | * @summary Clear the metadata from a tag, but keep the tag rather than stripping it from 1400 | * the image file. 1401 | * @author Matthew Duffy 1402 | * @async 1403 | * @throws { Error } Throws error if there is no valid path to an image file. 1404 | * @throws { Error } Throws error if the current path is to a directory instead of a file. 1405 | * @throws { Error } Throws error if the expected parameter is missing or of the wrong type. 1406 | * @throws { Error } Throws error if exiftool returns a fatal error via stderr. 1407 | * @param { String|String[] } tagsToClear - A string value with tag name or an array of tag 1408 | * names. 1409 | * @return { Object|Error } Returns an object literal with success or error messages, or 1410 | * throws an exception if no image given. 1411 | */ 1412 | async clearMetadataFromTag(tagsToClear) { 1413 | const log = debug.extend('clearMetadataFromTag') 1414 | const err = error.extend('clearMetadataFromTag') 1415 | const o = { value: null, errors: null } 1416 | let tagString = '' 1417 | if (this._path === null) { 1418 | const msg = 'No image was specified to clear metadata from tags.' 1419 | err(msg) 1420 | throw new Error(msg) 1421 | } 1422 | if (this._isDirectory) { 1423 | const msg = 'No image was specified to write new metadata content to.' 1424 | err(msg) 1425 | throw new Error(msg) 1426 | } 1427 | let eMsg 1428 | switch (tagsToClear.constructor) { 1429 | case Array: 1430 | tagString = tagsToClear.join(' ') 1431 | break 1432 | case String: 1433 | tagString = tagsToClear 1434 | break 1435 | default: 1436 | eMsg = `Expected a string or an arrray of strings. Recieved ${tagsToClear.constructor}` 1437 | err(eMsg) 1438 | throw new Error(eMsg) 1439 | } 1440 | try { 1441 | log(`tagString: ${tagString}`) 1442 | const file = `${this._path}` 1443 | const clear = `${this._executable} ${tagString} ${file}` 1444 | o.command = clear 1445 | const result = await cmd(clear) 1446 | if (result.stdout.trim() === null) { 1447 | const msg = `Failed to clear the tags: ${tagString}, from ${file}` 1448 | err(msg) 1449 | throw new Error(msg) 1450 | } 1451 | o.value = true 1452 | o.stdout = result.stdout.trim() 1453 | } catch (e) { 1454 | err(e) 1455 | o.error = e 1456 | } 1457 | return o 1458 | } 1459 | 1460 | /** 1461 | * Run the composed exiftool command to strip all the metadata from a file, keeping a backup 1462 | * copy of the original file. 1463 | * @summary Run the composed exiftool command to strip all the metadata from a file. 1464 | * @author Matthew Duffy 1465 | * @async 1466 | * @throws { Error } Throws error if instance property _path is missing. 1467 | * @throws { Error } Throws error if instance property _isDirectory is true. 1468 | * @throws { Error } Throws error if exiftool returns a fatal error via stderr. 1469 | * @return { (Object|Error) } Returns a JSON object literal with success message or throws 1470 | * an Error if failed. 1471 | */ 1472 | async stripMetadata() { 1473 | const log = debug.extend('stripMetadata') 1474 | const err = error.extend('stripMetadata') 1475 | const o = { value: null, error: null } 1476 | if (this._path === null) { 1477 | const msg = 'No image was specified to strip all metadata from.' 1478 | err(msg) 1479 | throw new Error(msg) 1480 | } 1481 | if (this._isDirectory) { 1482 | const msg = 'A directory was given. Use a path to a specific file instead.' 1483 | err(msg) 1484 | throw new Error(msg) 1485 | } 1486 | // exiftool -all= -o %f_copy%-.4nc.%e copper.jpg 1487 | const file = `${this._path}` 1488 | const strip = `${this._executable} ` 1489 | + `-config ${this._exiftool_config} ` 1490 | + `${this._opts.overwrite_original} -all= ` 1491 | + `${file}` 1492 | o.command = strip 1493 | try { 1494 | const result = await cmd(strip) 1495 | log(result) 1496 | if (result.stdout.trim().match(/files updated/) === null) { 1497 | throw new Error(`Failed to strip metadata from image - ${file}.`) 1498 | } 1499 | o.value = true 1500 | if (!this._opts.overwrite_original) { 1501 | o.original = `${file}_original` 1502 | } 1503 | } catch (e) { 1504 | o.value = false 1505 | o.error = e 1506 | err(o) 1507 | } 1508 | return o 1509 | } 1510 | 1511 | /** 1512 | * This method takes a single string parameter which is a fully composed metadata query to 1513 | * be passed directly to exiftool. 1514 | * @summary This method takes a single string parameter which is a fully composed metadata 1515 | * query to be passed directly to exiftool. 1516 | * @author Matthew Duffy 1517 | * @async 1518 | * @throws {Error} Throws error if the single string parameter is not provided. 1519 | * @throws {Error} Throws error if the exiftool command returns a fatal error via stderr. 1520 | * @param { String } query - A fully composed metadata to be passed directly to exiftool. 1521 | * @return { (Object|Error) } JSON object literal of metadata or throws an Error if failed. 1522 | */ 1523 | async raw(query) { 1524 | const log = debug.extend('raw') 1525 | const err = error.extend('raw') 1526 | if (query === '' || typeof query === 'undefined' || query.constructor !== String) { 1527 | const msg = 'No query was provided for exiftool to execute.' 1528 | err(msg) 1529 | throw new Error(msg) 1530 | } 1531 | let command = '' 1532 | const match = query.match(/(^[/?].*exiftool\s)/) 1533 | if (!match) { 1534 | if (this._executable === null) { 1535 | throw new Error( 1536 | 'No path to exiftool executable provided. Include exiftool path in query.', 1537 | ) 1538 | } 1539 | command = this._executable 1540 | } 1541 | command += ` ${query}` 1542 | try { 1543 | log(`raw query: ${query}`) 1544 | log(`raw command: ${command}`) 1545 | let result = await cmd(command) 1546 | log(result.stdout) 1547 | const tmp = JSON.parse(result.stdout?.trim()) 1548 | const tmperr = result.stderr 1549 | // let result = await _spawn(`'${this._command}'`) 1550 | // let tmp 1551 | // result.stdout.on('data', (data) => { 1552 | // log(`stdout: ${data}`) 1553 | // tmp = JSON.parse(data.trim()) 1554 | // }) 1555 | // let tmperr 1556 | // result.stderr.on('data', (data) => { 1557 | // // tmperr = result.stderr 1558 | // tmperr = data 1559 | // }) 1560 | result = tmp 1561 | result.push({ exiftool_command: command }) 1562 | result.push({ stderr: tmperr }) 1563 | log(result) 1564 | return result 1565 | } catch (e) { 1566 | e.exiftool_command = command 1567 | err(e) 1568 | return e 1569 | } 1570 | } 1571 | } 1572 | --------------------------------------------------------------------------------