diff --git a/.github/workflows/tartufo.yml b/.github/workflows/tartufo.yml new file mode 100644 index 0000000..1983a1f --- /dev/null +++ b/.github/workflows/tartufo.yml @@ -0,0 +1,21 @@ +name: 🍨 Tartufo 🏴‍☠️ + +on: [push] + +jobs: + tartufo: + name: Run Tartufo + runs-on: ubuntu-latest + steps: + - uses: "actions/checkout@v2" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Python 🐍 + uses: actions/setup-python@v2 + with: + python-version: 3.x + - name: Check for leaks 💦 + run: | + git pull --unshallow + pip install tartufo + tartufo --config tartufo.toml scan-local-repo . \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..64ca63d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: 🧪 Lint, Test & Check Coverage 🧐 + +on: [push] + +jobs: + build: + name: Test the build + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [12, 14] + + steps: + - uses: "actions/checkout@v2" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Setup NodeJS ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Install packages + run: npm install + - name: Lint & Test + run: npm run test \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..7cf58ac --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +/assets/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b7b4be5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,5 @@ +repos: +- repo: https://github.com/godaddy/tartufo + rev: v2.6.0 + hooks: + - id: tartufo diff --git a/README.md b/README.md index 286e838..b587cd9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,77 @@ -# node-im-metadata -Retrieve image metadata using ImageMagick's identify command +# im-metadata + +[![NPM downloads](https://img.shields.io/npm/dm/im-metadata.svg "NPM downloads")](https://www.npmjs.com/package/im-metadata) +[![NPM version](https://img.shields.io/npm/v/im-metadata.svg "NPM version")](https://www.npmjs.com/package/im-metadata) +[![Node version](https://img.shields.io/node/v/im-metadata.svg "Node version")](https://www.npmjs.com/package/im-metadata) +[![Dependency status](https://img.shields.io/david/turistforeningen/node-im-metadata.svg "Dependency status")](https://david-dm.org/turistforeningen/node-im-metadata) + +Retrieve image metadata as a JSON object using ImageMagick's `identify` command. + +## Requiremets + +* Node.JS v0.10 or newer +* ImageMagick v6.8 or newer + +## Install + +``` +npm install im-metadata --save +``` + +## API + +```js +var metadata = require('im-metadata'); +``` + +### metadata(**string** `src`, **object** `opts`, **function** `callback`) + +Return metadata **object** for a given `src` image. + +* **string** `src` - path to the image on disk +* **object** `opts` - metadata parsing options + * **boolean** `exif` - return exif data or not (default `false`) + * **boolean** `autoOrient` - auto-orient height/width (default `false`) + * **integer** `timeout` - command timeout length (default `5000`) +* **function** `callback` - callback function (**Error** `error`, **object** `data`) + * **Error** `error` - error output if command failed + * **object** `data` - parsed metadata object + +#### Return + +Returns an `object` with parsed metada: + +* **string** `path` - original image path +* **string** `name` - original image name +* **string** `size` - image file size in bytes (ex. `4504682`) +* **string** `format` - image format (`JPEG`, `PNG`, `TIFF` etc.) +* **string** `colorspace` - image colorspace (`RGB`, `CMYK` etc.) +* **integer** `height` - image pixel height +* **integer** `width` - image pixel width +* **string** `orientation` - image orientation + +#### Example + +```js +metadata('/path/to/image.jpg', {exif: true}, function(error, metadata) { + if (error) { console.error(error); } + console.log(metadata); + console.log(metadata.exif); +}); +``` + +## Contributing + +### Prerequisites + +``` +$ pip install pre-commit +``` + +### Installation + +``` +$ pre-commit install --install-hooks +``` + +## [MIT License](https://github.com/Turistforeningen/node-im-metadata/blob/master/LICENSE) diff --git a/image.jpg b/assets/image.jpg similarity index 100% rename from image.jpg rename to assets/image.jpg diff --git a/assets/orient.jpg b/assets/orient.jpg new file mode 100644 index 0000000..7e6f240 Binary files /dev/null and b/assets/orient.jpg differ diff --git a/docker-compose.yml b/docker-compose.yml index c4d7034..81230bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ -builder: - image: starefossen/iojs-imagemagick:1.6-6.9 +dev: + image: starefossen/iojs-imagemagick:2-6 working_dir: /usr/src/app volumes: - ".:/usr/src/app" - command: "node polling.js" + command: "npm run watch" diff --git a/index.js b/index.js index f5f6c73..8b4dd69 100644 --- a/index.js +++ b/index.js @@ -1,27 +1,30 @@ +/*jshint laxbreak:true */ + +var sizeParser = require('filesize-parser'); var exec = require('child_process').exec, child; -module.exports = function(src, opts, cb) { +module.exports = function(path, opts, cb) { if (!cb) { cb = opts; opts = {}; } - var cmd = module.exports.cmd(src, opts); + var cmd = module.exports.cmd(path, opts); opts.timeout = opts.timeout || 5000; exec(cmd, opts, function(e, stdout, stderr) { if (e) { return cb(e); } if (stderr) { return cb(new Error(stderr)); } - return cb(null, module.exports.parse(stdout)); + return cb(null, module.exports.parse(path, stdout, opts)); }); }; -module.exports.cmd = function(src, opts) { +module.exports.cmd = function(path, opts) { opts = opts || {}; var format = [ - 'name=%[name]', - 'size=%[size]', + 'name=', + 'size=%b', 'format=%m', 'colorspace=%[colorspace]', 'height=%[height]', @@ -30,29 +33,62 @@ module.exports.cmd = function(src, opts) { (opts.exif ? '%[exif:*]' : '') ].join("\n"); - return 'identify -format "' + format + '" ' + src; + return 'convert -ping ' + path + ' -format "' + format + '" -precision 12 info:'; }; -module.exports.parse = function(metadata) { - var lines = metadata.split('\n'), ret = {}, i; +module.exports.parse = function(path, stdout, opts) { + var lines = stdout.split('\n'); + var ret = {path: path}; + var i; for (i = 0; i < lines.length; i++) { if (lines[i]) { lines[i] = lines[i].split('='); - ret[lines[i][0]] = lines[i][1]; + + // Parse exif metadata keys + if (lines[i][0].substr(0, 5) === 'exif:') { + if (!ret.exif) { + ret.exif = {}; + } + + ret.exif[lines[i][0].substr(5)] = lines[i][1]; + + // Parse normal metadata keys + } else { + ret[lines[i][0]] = lines[i][1]; + } } } if (ret.width) { ret.width = parseInt(ret.width, 10); } if (ret.height) { ret.height = parseInt(ret.height, 10); } - if (ret.size && ret.size.substr(ret.size.length - 2) === "BB") { - ret.size = ret.size.substr(0, ret.size.length - 1); + if (ret.size) { + if (ret.size.substr(ret.size.length - 2) === 'BB') { + ret.size = ret.size.substr(0, ret.size.length - 1); + } + + ret.size = parseInt(sizeParser(ret.size)); } if (ret.colorspace && ret.colorspace === 'sRGB') { ret.colorspace = 'RGB'; } + if (ret.orientation === 'Undefined') { + ret.orientation = ''; + } + + if (opts && opts.autoOrient + && ( ret.orientation === 'LeftTop' + || ret.orientation === 'RightTop' + || ret.orientation === 'LeftBottom' + || ret.orientation === 'RightBottom')) { + + ret.width = ret.width + ret.height; + ret.height = ret.width - ret.height; + ret.width = ret.width - ret.height; + } + return ret; }; diff --git a/package.json b/package.json index b7a4da0..5263804 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,12 @@ { "name": "im-metadata", - "version": "1.0.2", + "version": "3.0.1", "description": "Retrieve image metadata using ImageMagick's identify command", "main": "index.js", "scripts": { - "test": "mocha test.js" + "test": "mocha test.js", + "hint": "./node_modules/.bin/jshint index.js test.js", + "watch": "./node_modules/.bin/mocha -w test.js" }, "repository": { "type": "git", @@ -24,6 +26,14 @@ }, "homepage": "https://github.com/Turistforeningen/node-im-metadata", "devDependencies": { - "mocha": "^2.2.4" + "jshint": "~2", + "mocha": "~3.0.2" + }, + "engines": { + "node": ">=0.10", + "iojs": ">=1.0.0" + }, + "dependencies": { + "filesize-parser": "^1.3.0" } } diff --git a/polling.js b/polling.js deleted file mode 100644 index 5751cee..0000000 --- a/polling.js +++ /dev/null @@ -1,30 +0,0 @@ -var watch = require('fs').watchFile; -var spawn = require('child_process').spawn; - -var opts = { interval: 50 }; - -watch('./test.js', opts, run); -watch('./index.js', opts, run); - -console.log('Watching test.js, index.js...'); - -var child = null; - -function run(curr, prev) { - if (curr.mtime === prev.mtime) { return; } - if (child) { child.kill('SIGHUP'); } - - child = spawn('./node_modules/.bin/mocha', ['test.js']); - child.stdout.on('data', function (data) { - process.stdout.write(data); - }); - - child.stderr.on('data', function (data) { - process.stderr.write(data); - }); - - child.on('close', function (code) { - child = null; - }); -} - diff --git a/tartufo.toml b/tartufo.toml new file mode 100644 index 0000000..ca1ad62 --- /dev/null +++ b/tartufo.toml @@ -0,0 +1,6 @@ +[tool.tartufo] +exclude-path-patterns = [ + 'package-lock.json', + 'tartufo.toml' +] +exclude-signatures = [] diff --git a/test.js b/test.js index 395df05..66ebfb6 100644 --- a/test.js +++ b/test.js @@ -5,73 +5,152 @@ var metadata = require('./index'); describe('metadata.cmd()', function() { it('returns command without exif data', function() { - var cmd = 'identify -format "name=%[name]\nsize=%[size]\nformat=%m\n' + var cmd = 'convert -ping /foo/bar/baz -format "name=\nsize=%b\nformat=%m\n' + 'colorspace=%[colorspace]\nheight=%[height]\nwidth=%[width]\n' - + 'orientation=%[orientation]\n" /foo/bar/baz'; + + 'orientation=%[orientation]\n" -precision 12 info:'; assert.equal(metadata.cmd('/foo/bar/baz'), cmd); }); it('returns command with exif data', function() { - var cmd = 'identify -format "name=%[name]\nsize=%[size]\nformat=%m\n' + var cmd = 'convert -ping /foo/bar/baz -format "name=\nsize=%b\nformat=%m\n' + 'colorspace=%[colorspace]\nheight=%[height]\nwidth=%[width]\n' - + 'orientation=%[orientation]\n%[exif:*]" /foo/bar/baz'; + + 'orientation=%[orientation]\n%[exif:*]" -precision 12 info:'; assert.equal(metadata.cmd('/foo/bar/baz', {exif: true}), cmd); }); }); describe('metadata.parse()', function() { + var path = '/foo/bar/baz.jpg'; + it('returns object for single value', function() { - assert.deepEqual(metadata.parse('foo=bar'), { + assert.deepEqual(metadata.parse(path, 'foo=bar'), { + path: path, foo: 'bar' }); }); it('returns object for metadata string', function() { - assert.deepEqual(metadata.parse('foo=bar\nbar=foo'), { + assert.deepEqual(metadata.parse(path, 'foo=bar\nbar=foo'), { + path: path, foo: 'bar', bar: 'foo' }); }); it('skips empty lines', function() { - assert.deepEqual(metadata.parse('foo=bar\n\nbar=foo\n\n'), { + assert.deepEqual(metadata.parse(path, 'foo=bar\n\nbar=foo\n\n'), { + path: path, foo: 'bar', bar: 'foo' }); }); it('returns correct size for bogus value', function() { - assert.deepEqual(metadata.parse('size=4.296MBB'), { - size: '4.296MB' + assert.deepEqual(metadata.parse(path, 'size=4.296MBB'), { + path: path, + size: 4504682 + }); + }); + + it('returns size in bytes', function() { + assert.deepEqual(metadata.parse(path, 'size=20MB'), { + path: path, + size: 20 * 1024 * 1024 }); }); it('returns RGB for sRGB colorspace', function() { - assert.deepEqual(metadata.parse('colorspace=sRGB'), { + assert.deepEqual(metadata.parse(path, 'colorspace=sRGB'), { + path: path, colorspace: 'RGB' }); }); + + it('returns "" for Undefined orientation', function() { + assert.deepEqual(metadata.parse(path, 'orientation=Undefined'), { + path: path, + orientation: '' + }); + }); + + it('returns height and widt for auto-orient', function() { + var meta = 'width=100\nheight=150\norientation='; + var opts = {autoOrient: true}; + + var orientation = [ + 'TopLeft', 'TopRight', 'BottomRight', 'BottomLeft', + 'LeftTop', 'RightTop', 'RightBottom', 'LeftBottom' + ]; + + for (var i = 0; i < 4; i++) { + assert.deepEqual(metadata.parse(path, meta + orientation[i], opts), { + height: 150, + width: 100, + path: path, + orientation: orientation[i] + }); + } + + for (var j = 4; j < 8; j++) { + assert.deepEqual(metadata.parse(path, meta + orientation[j], opts), { + height: 100, + width: 150, + path: path, + orientation: orientation[j] + }); + } + }); }); describe('metadata()', function() { it('returns metadata for image', function(done) { - metadata('./image.jpg', { exif: true }, function(err, data) { + metadata('./assets/image.jpg', { exif: false }, function(err, data) { + assert.ifError(err); + + assert.equal(data.path, './assets/image.jpg'); + assert.equal(data.name, ''); + assert.equal(data.size, 4295828); + assert.equal(data.format, 'JPEG'); + assert.equal(data.colorspace, 'RGB'); + assert.equal(data.height, 3456); + assert.equal(data.width, 5184); + assert.equal(data.orientation, 'TopLeft'); + + assert.equal(typeof data.exif, 'undefined'); + + done(); + }); + }); + + it('returns metadata for image with exif data', function(done) { + metadata('./assets/image.jpg', { exif: true }, function(err, data) { assert.ifError(err); + assert.equal(data.path, './assets/image.jpg'); assert.equal(data.name, ''); - assert.equal(data.size, '4.296MB'); + assert.equal(data.size, 4295828); assert.equal(data.format, 'JPEG'); assert.equal(data.colorspace, 'RGB'); assert.equal(data.height, 3456); assert.equal(data.width, 5184); + assert.equal(data.orientation, 'TopLeft'); + + assert.equal(typeof data.exif, 'object'); + assert.equal(Object.keys(data.exif).length, 41); + assert.equal(data.exif.ApertureValue, '37/8'); + + done(); + }); + }); + + it('returns correct height and width for auto-orient', function(done) { + metadata('./assets/orient.jpg', { autoOrient: true }, function(err, data) { + assert.ifError(err); - // Ok, I give up. For some reason there is this inconsistency between - // ImageMagick identify versions which yilds undefined/empty string for - // the orientation on the CI server. I can not find any reference on this - // issue which is why this test will not be run on the CI server. - if (!process.env.CI) { assert.equal(data.orientation, 'TopLeft'); } + assert.equal(data.height, 3264); + assert.equal(data.width, 2448); done(); }); diff --git a/wercker.yml b/wercker.yml index 698dd9a..0a3abd6 100644 --- a/wercker.yml +++ b/wercker.yml @@ -1,23 +1,29 @@ -box: wercker/nodejs +box: starefossen/iojs-imagemagick:2-6 build: steps: - - jshint: - version: 2.6 - - - npm-install - - npm-test - - script: name: echo nodejs information code: | echo "node version $(node -v) running" echo "npm version $(npm -v) running" + - script: + name: echo imagemagick information + code: echo "$(convert --version)" + + - npm-install + + - script: + name: jshint + code: | + npm run hint + + - npm-test + after-steps: - - wantedly/pretty-slack-notify: - webhook_url: $SLACK_WEBHOOK_URL + - turistforeningen/slack-notifier: + url: $SLACK_WEBHOOK_URL deploy: steps: - - kwakayama/npm-publish - + - turistforeningen/npm-publish