├── .gitignore ├── LICENSE ├── README.md ├── node-netpbm.js ├── package.json └── tests ├── .gitignore ├── sample.gif ├── sample.jpg ├── sample.mystery ├── sample.png └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 P'unk Avenue LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-netpbm 2 | 3 | node-netpbm scales and converts GIF, JPEG and PNG images asynchronously, without running out of memory, even if the original image is very large. It does this via the netpbm utilities, a classic package of simple Unix tools that convert image formats and perform operations row-by-row so that memory is not exhausted. If you have ever tried to import 16-megapixel JPEGs with gd or imagemagick, you know exactly why you need this package. 4 | 5 | node-netpbm also provides a simple way to check the dimensions of an existing image file without paying the full price of converting it. 6 | 7 | ## System Requirements 8 | 9 | You must have the [netpbm utilities](http://netpbm.sourceforge.net) installed. For best results, [install the "stable" or "advanced" version](http://netpbm.sourceforge.net/getting_netpbm.php) from the netpbm site. You can also use your operating system's package manager if you can live without support for preserving alpha channels in PNGs (as of this writing most Linux distributions have an older version of netpbm that can't do this). On Ubuntu systems this is all you need to do: 10 | 11 | apt-get install netpbm 12 | 13 | `node-netpbm` is designed for use on Linux, MacOS X and other Unix systems. No guarantees are made that it will work on Windows systems or anywhere else where shell pipelines don't behave reasonably and/or simple utilities like `head` and `tail` do not exist. But we'll accept pull requests. Make sure the tests pass! 14 | 15 | ## Converting and scaling images 16 | 17 | node-netpbm offers a very simple API for converting and scaling images: 18 | 19 | var convert = require('netpbm').convert; 20 | 21 | convert('input/file.png', 22 | 'output/file.jpg', 23 | { width: 300, height: 400 }, 24 | function(err) { 25 | if (!err) { 26 | console.log("Hooray, your image is ready!"); 27 | } 28 | } 29 | ); 30 | 31 | This code creates an image as close to 300x400 pixels as possible without distorting the aspect ratio of the original image. See below for more information about the options available. 32 | 33 | node-netpbm will automatically detect file types from file extensions. Uppercase is automatically converted to lowercase in file extensions, and jpeg is accepted as a synonym for jpg. 34 | 35 | ## Options for converting and scaling images 36 | 37 | The third parameter to `convert` is an object containing options such as `alpha`, `width`, `height` and `limit`. 38 | 39 | * If you specify `alpha: true` and you have a very up to date version of the netpbm utilities that includes the `pngtopam` and `pamrgbatopng` utilities (check at the command line), alpha channel will be preserved when scaling a PNG input file to a PNG output file. As of this writing Ubuntu does not include these in its netpbm package. For more information and source code download links, see the [netpbm site](http://netpbm.sourceforge.net/getting_netpbm.php). The "stable" and "advanced" tarballs have both utilities ("super stable" does not). 40 | 41 | * If you specify just `width`, the output image will be that wide, and the height will scale to maintain the aspect ratio of the original. 42 | 43 | * If you specify just `height`, the output image will be that tall, and the width will scale to maintain the aspect ratio of the original. 44 | 45 | * If you specify both `width` and `height` properties for the options parameter, the output image will be as close to that size as possible without changing the aspect ratio of the original. For instance, if the original is 2000x2000 and you specify 300x400, the output will be 300x300. If the original is 500x5000 and you specify 300x400, the output will be 40x400. 46 | 47 | A common use for the third approach is to specify the width you typically want but also specify a maximum height to avoid unwanted results if the original is extremely tall, like an infographic. 48 | 49 | * If you are processing many image uploads for many users simultaneously, spawning lots of image processing Unix pipelines asynchronously could use a lot of resources. To prevent this, node-netpbm automatically throttles the number of simultaneously pending pipelines to 10. Additional requests will automatically wait until a slot is available. You can override this by setting the `limit` option to a different value. There isn't much benefit in setting this option higher than the number of cores available to you. In fact, if you are using the cluster module to run a node process for each core, you might want to set `limit` to 1 so that each process does not spawn up to 10 image pipelines. 50 | 51 | * If you specify the `typeOut` option, the output image will have the type specified by this option, which may be `png`, `jpg` or `gif`. This overrides the automatic detection that normally takes place based on the file extension of your output filename. 52 | 53 | * Additional options exist for advanced uses such as overriding the netpbm utilities used for each conversion. See the source code for details. 54 | 55 | ## Obtaining image dimensions 56 | 57 | Here's how: 58 | 59 | var info = require('netpbm').info; 60 | 61 | info('file.jpg', function(err, result) { 62 | if (!err) { 63 | console.log("Type: " + result.type + 64 | " width: " + result.width + 65 | " height: " + result.height); 66 | } 67 | }); 68 | 69 | Like `convert`, `info` is asynchronous. If there is no error, the type, width and height are passed to the callback via the `result` object. 70 | 71 | The `type` property will contain `gif`, `jpg` or `png`. `width` and `height` are hopefully self-explanatory. 72 | 73 | You can also call `info` with three parameters: the filename, an options object, and the callback. Usually you won't need this, but `info` does support the same advanced parameters for overriding types as `convert` does. `info` currently does not support the `limit` option, however obtaining image dimensions has a much smaller impact on the system than actually converting or scaling a complete image. 74 | 75 | Although the `info` function is reasonably fast, you should not rely on calling it every time you display an image. For good performance you should cache everything you know about each image in your database. 76 | 77 | ## Contributing Code 78 | 79 | We love pull requests. But you gotta make sure the tests still pass. cd to the `tests` folder and run `node test.js`. If it blows up, your code isn't ready. 80 | 81 | ## Changelog 82 | 83 | 1.1.1: don't leak global variables. Thanks to Nathan Wall. 84 | 85 | 1.1.0: `typeOut` option for times when you want to specify the output format explicitly without relying on a file extension in the output filename. Thanks to [calibr](https://github.com/calibr). 86 | 87 | 1.0.2: Child process concurrency managed by [async.queue](https://github.com/caolan/async#queue). No change in documented behavior. 88 | 89 | 1.0.1: The `limit` option is respected even when only fetcing `info` to prevent crashes due to resource starvation. Thanks to Alex / Ajax. 90 | 91 | 1.0.0: Prevent `node-netpbm` from crashing if the netpbm utilities produce no output without an error code (thanks to Alexander Johansson). Also decided to declare 1.0.0 stable since this is the first change in many moons. 92 | 93 | ## Contact 94 | 95 | Created at [P'unk Avenue](http://punkave.com), an amazing design-and-build firm in South Philly. Feel free to drop [Tom Boutell](mailto:tom@punkave.com) a line with questions. Better yet, send pull requests and open issues on [http://github.com/punkave/node-netpbm](github). 96 | 97 | -------------------------------------------------------------------------------- /node-netpbm.js: -------------------------------------------------------------------------------- 1 | var child_process = require('child_process'); 2 | var async = require('async'); 3 | 4 | var exec_queue = async.queue(function(command, callback) { 5 | child_process.exec(command, callback); 6 | }); 7 | 8 | exec_queue.concurrency = 10; 9 | 10 | // Callback's first argument is error if any. Second 11 | // argument (if no error) is an object with width, height 12 | // and type properties (gif, jpg, png or as redefined by 13 | // options, see convert). The "options" argument is not 14 | // mandatory and is usually unnecessary for info(). 15 | 16 | module.exports.info = function(fileIn, options, callback) 17 | { 18 | if (typeof(options) === 'function') { 19 | callback = options; 20 | options = {}; 21 | } 22 | if (!options) { 23 | options = {}; 24 | } 25 | options.infoOnly = true; 26 | module.exports.convert(fileIn, null, options, callback); 27 | } 28 | 29 | module.exports.convert = function(fileIn, fileOut, options, callback) 30 | { 31 | if (!options) { 32 | options = {}; 33 | } 34 | // By default no more than 10 image processing pipelines are forked at any one time. 35 | // Additional requests wait patiently for their turn. If you are using the cluster 36 | // module you might want a lower limit as each node process gets its own pool 37 | // of image conversion processes 38 | if (options.limit) { 39 | exec_queue.concurrency = options.limit; 40 | } 41 | 42 | var typeMap = options.typeMap ? options.typeMap : { 43 | 'jpeg': 'jpg' 44 | }; 45 | 46 | var jpegQuality = options.jpegQuality ? options.jpegQuality : 85; 47 | 48 | var types = options.types ? options.types : [ 49 | { 50 | name: 'jpg', 51 | importer: 'jpegtopnm ', 52 | exporter: 'pnmtojpeg -quality ' + jpegQuality + ' ', 53 | fileCommandReports: 'JPEG' 54 | }, 55 | { 56 | name: 'png', 57 | // Preseve alpha channel in PNGs. Generally they are not used 58 | // in this context unless it's worth it. However most (all?) 59 | // Linux distributions don't include a new enough netpbm, so 60 | // default to not doing it 61 | importer: options.alpha ? 'pngtopam -byrow -alphapam ' : 'pngtopnm -mix ', 62 | exporter: 'pnmtopng ', 63 | sameTypeExporter: options.alpha ? 'pamrgbatopng ' : 'pnmtopng ', 64 | fileCommandReports: 'PNG' 65 | }, 66 | { 67 | name: 'gif', 68 | importer: 'giftopnm ', 69 | exporter: 'ppmquant 256 | ppmtogif ', 70 | fileCommandReports: 'GIF' 71 | } 72 | ]; 73 | 74 | if (options.extraTypes) { 75 | types = types.concat(options.extraTypes); 76 | } 77 | 78 | var typesByName = {}; 79 | var i; 80 | for (i = 0; (i < types.length); i++) { 81 | typesByName[types[i].name] = types[i]; 82 | } 83 | 84 | var typeOut = options.typeOut; 85 | if (!options.infoOnly && !typeOut) { 86 | var typeOut = typeByExtension(fileOut); 87 | if (!typeOut) { 88 | callback('unsupported output file extension: ' + fileOut); 89 | return; 90 | } 91 | } 92 | 93 | var typeIn = typeByExtension(fileIn); 94 | if (typeIn) { 95 | preparePipeline(); 96 | } 97 | else 98 | { 99 | // typeByHeader is async 100 | typeByHeader(fileIn, function(err, result) { 101 | if (err) { 102 | callback(err); 103 | return; 104 | } 105 | typeIn = result; 106 | preparePipeline(); 107 | }); 108 | } 109 | 110 | function preparePipeline() { 111 | 112 | // Coding convention: each time cmd is appended to, you are responsible for including a trailing space. Not the next guy. 113 | var cmd = typesByName[typeIn].importer + "< " + escapeshellarg(fileIn) + " "; 114 | 115 | if (options.infoOnly) { 116 | var result = {}; 117 | // Due to the row-by-row processing of the netpbm utilities, 118 | // reading the width and height from the intermediate 119 | // .pam/.ppm file and then shutting down the pipeline is 120 | // fast, much faster than completely converting the whole thing 121 | // just to learn the dimensions would be. So this is not as 122 | // inefficient as it seems 123 | cmd += "| head -3 "; 124 | result.type = typeIn; 125 | exec_queue.push(cmd, function(err, stdout, stderr) { 126 | if (err) { 127 | callback(err + ': ' + stderr); 128 | return; 129 | } 130 | 131 | if (!stdout) { 132 | callback("No netpbm output"); 133 | return; 134 | } 135 | 136 | var lines = stdout.split(/[\r\n]+/); 137 | // PAM files are different, sigh 138 | if (lines[1].match(/^WIDTH (\d+)/) && lines[2].match(/^HEIGHT (\d+)/)) 139 | { 140 | var matches = lines[1].match(/^WIDTH (\d+)/); 141 | result.width = parseInt(matches[1]); 142 | var matches = lines[2].match(/^HEIGHT (\d+)/); 143 | result.height = parseInt(matches[1]); 144 | } else { 145 | var matches = lines[1].match(/^(\d+) (\d+)/); 146 | if (matches) { 147 | result.width = parseInt(matches[1]); 148 | result.height = parseInt(matches[2]); 149 | } 150 | } 151 | if (result.width && result.height) { 152 | callback(null, result); 153 | return; 154 | } 155 | callback("Unexpected netpbm output"); 156 | return; 157 | }); 158 | return; 159 | } 160 | 161 | var scaler, fitter; 162 | if (options.alpha) { 163 | scaler = 'pamscale '; 164 | fitter = '-xyfit '; 165 | } else { 166 | scaler = 'pnmscale '; 167 | fitter = '-xysize '; 168 | } 169 | 170 | if (options.width && options.height) { 171 | cmd += "| " + scaler + fitter + options.width + ' ' + options.height + ' '; 172 | } else if (options.width) { 173 | cmd += "| " + scaler + "-width " + options.width + ' '; 174 | } else if (options.height) { 175 | cmd += "| " + scaler + "-height " + options.height + ' '; 176 | } else { 177 | // Size unchanged 178 | } 179 | 180 | // Watermark image is centered on the main image. Must be a .pam file 181 | // (with an alpha channel, for best results) 182 | if (options.watermark) { 183 | cmd += "| pamcomp -align=center -valign=middle " + escapeshellarg(options.watermark) + ' '; 184 | } 185 | 186 | var exporter = (typesByName[typeOut].sameTypeExporter && (typeIn === typeOut)) ? typesByName[typeOut].sameTypeExporter : typesByName[typeOut].exporter; 187 | cmd += "| " + exporter + "> " + escapeshellarg(fileOut); 188 | 189 | exec_queue.push(cmd, function(err, stdout, stderr) { 190 | if (err) { 191 | callback(err + ': ' + stderr); 192 | } 193 | else 194 | { 195 | // All is well - the desired result is in fileOut 196 | callback(null); 197 | } 198 | }); 199 | } 200 | 201 | function typeByExtension(filename) { 202 | var result = filename.match(/\.(\w+)$/); 203 | if (result) { 204 | var extension = result[1]; 205 | extension = extension.toLowerCase(); 206 | if (typeMap[extension]) { 207 | extension = typeMap[extension]; 208 | } 209 | if (typesByName[extension]) { 210 | return extension; 211 | } 212 | } 213 | return false; 214 | } 215 | 216 | function typeByHeader(filename, callback) { 217 | var cmd = 'file ' + escapeshellarg(filename); 218 | exec_queue.push(cmd, function(err, stdout, stderr) { 219 | if (err) { 220 | callback(err + ': ' + stderr); 221 | return; 222 | } 223 | var i; 224 | for (i = 0; (i < types.length); i++) { 225 | var type = types[i]; 226 | if (stdout.indexOf(type.fileCommandReports) !== -1) { 227 | 228 | callback(null, type.name); 229 | return; 230 | } 231 | } 232 | callback('Unknown'); 233 | }); 234 | } 235 | 236 | // http://phpjs.org/functions/escapeshellarg:866 237 | function escapeshellarg(arg) { 238 | // Quote and escape an argument for use in a shell command 239 | // 240 | // version: 1109.2015 241 | // discuss at: http://phpjs.org/functions/escapeshellarg 242 | // + original by: Felix Geisendoerfer (http://www.debuggable.com/felix) 243 | // + improved by: Brett Zamir (http://brett-zamir.me) 244 | // * example 1: escapeshellarg("kevin's birthday"); 245 | // * returns 1: "'kevin\'s birthday'" 246 | var ret = ''; 247 | 248 | ret = arg.replace(/[^\\]'/g, function (m, i, s) { 249 | return m.slice(0, 1) + '\\\''; 250 | }); 251 | 252 | return "'" + ret + "'"; 253 | } 254 | }; 255 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "P'unk Avenue LLC (http://punkave.com/)", 3 | "name": "netpbm", 4 | "description": "Convert and scale JPEG, GIF and PNG images without running out of memory, even when the images are very large. Fully asynchronous.", 5 | "version": "1.1.1", 6 | "license": "MIT", 7 | "engines": { 8 | "node": ">=0.8" 9 | }, 10 | "homepage": "http://github.com/punkave/node-netpbm", 11 | "repository": { 12 | "type": "git", 13 | "url": "git://github.com/punkave/node-netpbm.git" 14 | }, 15 | "main": "node-netpbm.js", 16 | "scripts": { 17 | "test": "cd tests; node test.js" 18 | }, 19 | "dependencies": { 20 | "async": "~0.2.10" 21 | }, 22 | "devDependencies": {}, 23 | "optionalDependencies": {}, 24 | "engines": { 25 | "node": "*" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | test*.gif 2 | test*.png 3 | test*.jpg 4 | test7 5 | -------------------------------------------------------------------------------- /tests/sample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/punkave/node-netpbm/3c58cfb179c27135ffe17591c8d62f6dd54601dc/tests/sample.gif -------------------------------------------------------------------------------- /tests/sample.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/punkave/node-netpbm/3c58cfb179c27135ffe17591c8d62f6dd54601dc/tests/sample.jpg -------------------------------------------------------------------------------- /tests/sample.mystery: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/punkave/node-netpbm/3c58cfb179c27135ffe17591c8d62f6dd54601dc/tests/sample.mystery -------------------------------------------------------------------------------- /tests/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/punkave/node-netpbm/3c58cfb179c27135ffe17591c8d62f6dd54601dc/tests/sample.png -------------------------------------------------------------------------------- /tests/test.js: -------------------------------------------------------------------------------- 1 | var failures = 0; 2 | 3 | var convert = require('../node-netpbm.js').convert; 4 | var info = require('../node-netpbm.js').info; 5 | var exec = require('child_process').exec; 6 | 7 | // Change me to true to test alpha preservation 8 | // support for PNG-to-PNG conversions. This won't 9 | // work for you if you don't have a newer netpbm 10 | // with several new commands, see the documentation. 11 | var alpha = false; 12 | 13 | // Make sure netpbm is available before plunging ahead 14 | 15 | requirements(); 16 | 17 | function requirements() { 18 | process.stdout.write('System requirements: '); 19 | exec('pnmtopng --version && pngtopnm --version && pnmscale --version', function(err, stdout, stderr) { 20 | if (err) { 21 | console.log("NOT MET"); 22 | console.log("You do not have the netpbm utilities installed, or they are"); 23 | console.log("out of date. Make sure you have pnmtopng, pngtopnm and pamscale"); 24 | console.log("commands. Ubuntu hint: apt-get install netpbm"); 25 | process.exit(2); 26 | } 27 | console.log("MET"); 28 | test1(); 29 | }); 30 | } 31 | 32 | // Pass-through conversion: GIF to GIF, no size change 33 | 34 | function test1() { 35 | process.stdout.write('test 1: '); 36 | convert('sample.gif', 'test1.gif', { alpha: alpha }, next('test1.gif', 'gif', 350, 361, test2)); 37 | } 38 | 39 | // Convert GIF to JPEG 40 | 41 | function test2() { 42 | process.stdout.write('test 2: '); 43 | convert('sample.gif', 'test2.jpg', { alpha: alpha }, next('test2.jpg', 'jpg', 350, 361, test3)); 44 | } 45 | 46 | // JPEG to JPEG with size change: as big as possible while 47 | // fitting in 300px x 300px without changing the aspect ratio 48 | 49 | function test3() { 50 | process.stdout.write('test 3: '); 51 | convert('sample.jpg', 'test3.jpg', { alpha: alpha, 'width': 300, 'height': 300}, next('test3.jpg', 'jpg', 224, 300, test4)); 52 | } 53 | 54 | // JPEG to PNG with size change: width of exactly 300px 55 | 56 | function test4() { 57 | process.stdout.write('test 4: '); 58 | convert('sample.jpg', 'test4.png', { alpha: alpha, 'width': 300 }, next('test4.png', 'png', 300, 402, test5)); 59 | } 60 | 61 | // PNG to PNG with size change: height of exactly 300px. 62 | // Note the 'file' command shows the alpha channel was preserved 63 | 64 | function test5() { 65 | process.stdout.write('test 5: '); 66 | convert('sample.png', 'test5.png', { alpha: alpha, 'height': 300 }, next('test5.png', 'png', 279, 300, test6)); 67 | } 68 | 69 | // Just like test6, but the file type has to be determined 70 | // by inspection of the file 71 | 72 | function test6() { 73 | process.stdout.write('test 6: '); 74 | convert('sample.mystery', 'test6.png', { alpha: alpha, 'height': 300 }, next('test6.png', 'png', 279, 300, test7)); 75 | } 76 | 77 | // Output image filename without extension, but type specified in typeOut param 78 | 79 | function test7() { 80 | process.stdout.write('test 7: '); 81 | convert('sample.png', 'test7', { alpha: alpha, typeOut: "jpg", height: 300 }, next('test7', 'jpg', 279, 300, null)); 82 | } 83 | 84 | // Confirm that we like the results, then call the next test if any 85 | 86 | function next(filename, type, width, height, nextTest) { 87 | return function(err) { 88 | if (err) { 89 | console.log("FAILED: " + err); 90 | } 91 | else 92 | { 93 | // Confirm file type, width and height are actually correct 94 | // before going on to the next test 95 | info(filename, {}, function(err, info) { 96 | if (err) { 97 | console.log("FAILED: " + err); 98 | } else if (info.type !== type) { 99 | console.log("FAILED: type is " + info.type + ", not " + type); 100 | } else if (info.width !== width) { 101 | console.log("FAILED: width is " + info.width + ", not " + width); 102 | } else if (info.height !== height) { 103 | console.log("FAILED: height is " + info.height + ", not " + height); 104 | } else { 105 | console.log("SUCCESS"); 106 | } 107 | if (nextTest) { 108 | nextTest(); 109 | return; 110 | } 111 | // If any tests failed return a nonzero exit status 112 | // so that tools like Jenkins can spot it 113 | if (failures) { 114 | process.exit(1); 115 | } 116 | }); 117 | } 118 | }; 119 | } 120 | --------------------------------------------------------------------------------