123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579 |
- /* jshint quotmark: false */
- 'use strict';
- var FS = require('fs'),
- PATH = require('path'),
- chalk = require('chalk'),
- mkdirp = require('mkdirp'),
- promisify = require('util.promisify'),
- readdir = promisify(FS.readdir),
- readFile = promisify(FS.readFile),
- writeFile = promisify(FS.writeFile),
- SVGO = require('../svgo.js'),
- YAML = require('js-yaml'),
- PKG = require('../../package.json'),
- encodeSVGDatauri = require('./tools.js').encodeSVGDatauri,
- decodeSVGDatauri = require('./tools.js').decodeSVGDatauri,
- checkIsDir = require('./tools.js').checkIsDir,
- regSVGFile = /\.svg$/,
- noop = () => {},
- svgo;
- /**
- * Command-Option-Argument.
- *
- * @see https://github.com/veged/coa
- */
- module.exports = require('coa').Cmd()
- .helpful()
- .name(PKG.name)
- .title(PKG.description)
- .opt()
- .name('version').title('Version')
- .short('v').long('version')
- .only()
- .flag()
- .act(function() {
- // output the version to stdout instead of stderr if returned
- process.stdout.write(PKG.version + '\n');
- // coa will run `.toString` on the returned value and send it to stderr
- return '';
- })
- .end()
- .opt()
- .name('input').title('Input file, "-" for STDIN')
- .short('i').long('input')
- .arr()
- .val(function(val) {
- return val || this.reject("Option '--input' must have a value.");
- })
- .end()
- .opt()
- .name('string').title('Input SVG data string')
- .short('s').long('string')
- .end()
- .opt()
- .name('folder').title('Input folder, optimize and rewrite all *.svg files')
- .short('f').long('folder')
- .val(function(val) {
- return val || this.reject("Option '--folder' must have a value.");
- })
- .end()
- .opt()
- .name('output').title('Output file or folder (by default the same as the input), "-" for STDOUT')
- .short('o').long('output')
- .arr()
- .val(function(val) {
- return val || this.reject("Option '--output' must have a value.");
- })
- .end()
- .opt()
- .name('precision').title('Set number of digits in the fractional part, overrides plugins params')
- .short('p').long('precision')
- .val(function(val) {
- return !isNaN(val) ? val : this.reject("Option '--precision' must be an integer number");
- })
- .end()
- .opt()
- .name('config').title('Config file or JSON string to extend or replace default')
- .long('config')
- .val(function(val) {
- return val || this.reject("Option '--config' must have a value.");
- })
- .end()
- .opt()
- .name('disable').title('Disable plugin by name, "--disable={PLUGIN1,PLUGIN2}" for multiple plugins (*nix)')
- .long('disable')
- .arr()
- .val(function(val) {
- return val || this.reject("Option '--disable' must have a value.");
- })
- .end()
- .opt()
- .name('enable').title('Enable plugin by name, "--enable={PLUGIN3,PLUGIN4}" for multiple plugins (*nix)')
- .long('enable')
- .arr()
- .val(function(val) {
- return val || this.reject("Option '--enable' must have a value.");
- })
- .end()
- .opt()
- .name('datauri').title('Output as Data URI string (base64, URI encoded or unencoded)')
- .long('datauri')
- .val(function(val) {
- return val || this.reject("Option '--datauri' must have one of the following values: 'base64', 'enc' or 'unenc'");
- })
- .end()
- .opt()
- .name('multipass').title('Pass over SVGs multiple times to ensure all optimizations are applied')
- .long('multipass')
- .flag()
- .end()
- .opt()
- .name('pretty').title('Make SVG pretty printed')
- .long('pretty')
- .flag()
- .end()
- .opt()
- .name('indent').title('Indent number when pretty printing SVGs')
- .long('indent')
- .val(function(val) {
- return !isNaN(val) ? val : this.reject("Option '--indent' must be an integer number");
- })
- .end()
- .opt()
- .name('recursive').title('Use with \'-f\'. Optimizes *.svg files in folders recursively.')
- .short('r').long('recursive')
- .flag()
- .end()
- .opt()
- .name('quiet').title('Only output error messages, not regular status messages')
- .short('q').long('quiet')
- .flag()
- .end()
- .opt()
- .name('show-plugins').title('Show available plugins and exit')
- .long('show-plugins')
- .flag()
- .end()
- .arg()
- .name('input').title('Alias to --input')
- .arr()
- .end()
- .act(function(opts, args) {
- var input = opts.input || args.input,
- output = opts.output,
- config = {};
- // --show-plugins
- if (opts['show-plugins']) {
- showAvailablePlugins();
- return;
- }
- // w/o anything
- if (
- (!input || input[0] === '-') &&
- !opts.string &&
- !opts.stdin &&
- !opts.folder &&
- process.stdin.isTTY === true
- ) return this.usage();
- if (typeof process == 'object' && process.versions && process.versions.node && PKG && PKG.engines.node) {
- var nodeVersion = String(PKG.engines.node).match(/\d*(\.\d+)*/)[0];
- if (parseFloat(process.versions.node) < parseFloat(nodeVersion)) {
- return printErrorAndExit(`Error: ${PKG.name} requires Node.js version ${nodeVersion} or higher.`);
- }
- }
- // --config
- if (opts.config) {
- // string
- if (opts.config.charAt(0) === '{') {
- try {
- config = JSON.parse(opts.config);
- } catch (e) {
- return printErrorAndExit(`Error: Couldn't parse config JSON.\n${String(e)}`);
- }
- // external file
- } else {
- var configPath = PATH.resolve(opts.config),
- configData;
- try {
- // require() adds some weird output on YML files
- configData = FS.readFileSync(configPath, 'utf8');
- config = JSON.parse(configData);
- } catch (err) {
- if (err.code === 'ENOENT') {
- return printErrorAndExit(`Error: couldn't find config file '${opts.config}'.`);
- } else if (err.code === 'EISDIR') {
- return printErrorAndExit(`Error: directory '${opts.config}' is not a config file.`);
- }
- config = YAML.safeLoad(configData);
- config.__DIR = PATH.dirname(configPath); // will use it to resolve custom plugins defined via path
- if (!config || Array.isArray(config)) {
- return printErrorAndExit(`Error: invalid config file '${opts.config}'.`);
- }
- }
- }
- }
- // --quiet
- if (opts.quiet) {
- config.quiet = opts.quiet;
- }
- // --recursive
- if (opts.recursive) {
- config.recursive = opts.recursive;
- }
- // --precision
- if (opts.precision) {
- var precision = Math.min(Math.max(0, parseInt(opts.precision)), 20);
- if (!isNaN(precision)) {
- config.floatPrecision = precision;
- }
- }
- // --disable
- if (opts.disable) {
- changePluginsState(opts.disable, false, config);
- }
- // --enable
- if (opts.enable) {
- changePluginsState(opts.enable, true, config);
- }
- // --multipass
- if (opts.multipass) {
- config.multipass = true;
- }
- // --pretty
- if (opts.pretty) {
- config.js2svg = config.js2svg || {};
- config.js2svg.pretty = true;
- var indent;
- if (opts.indent && !isNaN(indent = parseInt(opts.indent))) {
- config.js2svg.indent = indent;
- }
- }
- svgo = new SVGO(config);
- // --output
- if (output) {
- if (input && input[0] != '-') {
- if (output.length == 1 && checkIsDir(output[0])) {
- var dir = output[0];
- for (var i = 0; i < input.length; i++) {
- output[i] = checkIsDir(input[i]) ? input[i] : PATH.resolve(dir, PATH.basename(input[i]));
- }
- } else if (output.length < input.length) {
- output = output.concat(input.slice(output.length));
- }
- }
- } else if (input) {
- output = input;
- } else if (opts.string) {
- output = '-';
- }
- if (opts.datauri) {
- config.datauri = opts.datauri;
- }
- // --folder
- if (opts.folder) {
- var ouputFolder = output && output[0] || opts.folder;
- return optimizeFolder(config, opts.folder, ouputFolder).then(noop, printErrorAndExit);
- }
- // --input
- if (input) {
- // STDIN
- if (input[0] === '-') {
- return new Promise((resolve, reject) => {
- var data = '',
- file = output[0];
- process.stdin
- .on('data', chunk => data += chunk)
- .once('end', () => processSVGData(config, {input: 'string'}, data, file).then(resolve, reject));
- });
- // file
- } else {
- return Promise.all(input.map((file, n) => optimizeFile(config, file, output[n])))
- .then(noop, printErrorAndExit);
- }
- // --string
- } else if (opts.string) {
- var data = decodeSVGDatauri(opts.string);
- return processSVGData(config, {input: 'string'}, data, output[0]);
- }
- });
- /**
- * Change plugins state by names array.
- *
- * @param {Array} names plugins names
- * @param {Boolean} state active state
- * @param {Object} config original config
- * @return {Object} changed config
- */
- function changePluginsState(names, state, config) {
- names.forEach(flattenPluginsCbk);
- // extend config
- if (config.plugins) {
- for (var name of names) {
- var matched = false,
- key;
- for (var plugin of config.plugins) {
- // get plugin name
- if (typeof plugin === 'object') {
- key = Object.keys(plugin)[0];
- } else {
- key = plugin;
- }
- // if there is such a plugin name
- if (key === name) {
- // don't replace plugin's params with true
- if (typeof plugin[key] !== 'object' || !state) {
- plugin[key] = state;
- }
- // mark it as matched
- matched = true;
- }
- }
- // if not matched and current config is not full
- if (!matched && !config.full) {
- // push new plugin Object
- config.plugins.push({ [name]: state });
- matched = true;
- }
- }
- // just push
- } else {
- config.plugins = names.map(name => ({ [name]: state }));
- }
- return config;
- }
- /**
- * Flatten an array of plugins by invoking this callback on each element
- * whose value may be a comma separated list of plugins.
- *
- * @param {String} name Plugin name
- * @param {Number} index Plugin index
- * @param {Array} names Plugins being traversed
- */
- function flattenPluginsCbk(name, index, names)
- {
- var split = name.split(',');
- if(split.length > 1) {
- names[index] = split.shift();
- names.push.apply(names, split);
- }
- }
- /**
- * Optimize SVG files in a directory.
- * @param {Object} config options
- * @param {string} dir input directory
- * @param {string} output output directory
- * @return {Promise}
- */
- function optimizeFolder(config, dir, output) {
- if (!config.quiet) {
- console.log(`Processing directory '${dir}':\n`);
- }
- return readdir(dir).then(files => processDirectory(config, dir, files, output));
- }
- /**
- * Process given files, take only SVG.
- * @param {Object} config options
- * @param {string} dir input directory
- * @param {Array} files list of file names in the directory
- * @param {string} output output directory
- * @return {Promise}
- */
- function processDirectory(config, dir, files, output) {
- // take only *.svg files, recursively if necessary
- var svgFilesDescriptions = getFilesDescriptions(config, dir, files, output);
- return svgFilesDescriptions.length ?
- Promise.all(svgFilesDescriptions.map(fileDescription => optimizeFile(config, fileDescription.inputPath, fileDescription.outputPath))) :
- Promise.reject(new Error(`No SVG files have been found in '${dir}' directory.`));
- }
- /**
- * Get svg files descriptions
- * @param {Object} config options
- * @param {string} dir input directory
- * @param {Array} files list of file names in the directory
- * @param {string} output output directory
- * @return {Array}
- */
- function getFilesDescriptions(config, dir, files, output) {
- const filesInThisFolder = files
- .filter(name => regSVGFile.test(name))
- .map(name => ({
- inputPath: PATH.resolve(dir, name),
- outputPath: PATH.resolve(output, name),
- }));
- return config.recursive ?
- [].concat(
- filesInThisFolder,
- files
- .filter(name => checkIsDir(PATH.resolve(dir, name)))
- .map(subFolderName => {
- const subFolderPath = PATH.resolve(dir, subFolderName);
- const subFolderFiles = FS.readdirSync(subFolderPath);
- const subFolderOutput = PATH.resolve(output, subFolderName);
- return getFilesDescriptions(config, subFolderPath, subFolderFiles, subFolderOutput);
- })
- .reduce((a, b) => [].concat(a, b), [])
- ) :
- filesInThisFolder;
- }
- /**
- * Read SVG file and pass to processing.
- * @param {Object} config options
- * @param {string} file
- * @param {string} output
- * @return {Promise}
- */
- function optimizeFile(config, file, output) {
- return readFile(file, 'utf8').then(
- data => processSVGData(config, {input: 'file', path: file}, data, output, file),
- error => checkOptimizeFileError(config, file, output, error)
- );
- }
- /**
- * Optimize SVG data.
- * @param {Object} config options
- * @param {string} data SVG content to optimize
- * @param {string} output where to write optimized file
- * @param {string} [input] input file name (being used if output is a directory)
- * @return {Promise}
- */
- function processSVGData(config, info, data, output, input) {
- var startTime = Date.now(),
- prevFileSize = Buffer.byteLength(data, 'utf8');
- return svgo.optimize(data, info).then(function(result) {
- if (config.datauri) {
- result.data = encodeSVGDatauri(result.data, config.datauri);
- }
- var resultFileSize = Buffer.byteLength(result.data, 'utf8'),
- processingTime = Date.now() - startTime;
- return writeOutput(input, output, result.data).then(function() {
- if (!config.quiet && output != '-') {
- if (input) {
- console.log(`\n${PATH.basename(input)}:`);
- }
- printTimeInfo(processingTime);
- printProfitInfo(prevFileSize, resultFileSize);
- }
- },
- error => Promise.reject(new Error(error.code === 'ENOTDIR' ? `Error: output '${output}' is not a directory.` : error)));
- });
- }
- /**
- * Write result of an optimization.
- * @param {string} input
- * @param {string} output output file name. '-' for stdout
- * @param {string} data data to write
- * @return {Promise}
- */
- function writeOutput(input, output, data) {
- if (output == '-') {
- console.log(data);
- return Promise.resolve();
- }
- mkdirp.sync(PATH.dirname(output));
- return writeFile(output, data, 'utf8').catch(error => checkWriteFileError(input, output, data, error));
- }
- /**
- * Write a time taken by optimization.
- * @param {number} time time in milliseconds.
- */
- function printTimeInfo(time) {
- console.log(`Done in ${time} ms!`);
- }
- /**
- * Write optimizing information in human readable format.
- * @param {number} inBytes size before optimization.
- * @param {number} outBytes size after optimization.
- */
- function printProfitInfo(inBytes, outBytes) {
- var profitPercents = 100 - outBytes * 100 / inBytes;
- console.log(
- (Math.round((inBytes / 1024) * 1000) / 1000) + ' KiB' +
- (profitPercents < 0 ? ' + ' : ' - ') +
- chalk.green(Math.abs((Math.round(profitPercents * 10) / 10)) + '%') + ' = ' +
- (Math.round((outBytes / 1024) * 1000) / 1000) + ' KiB'
- );
- }
- /**
- * Check for errors, if it's a dir optimize the dir.
- * @param {Object} config
- * @param {string} input
- * @param {string} output
- * @param {Error} error
- * @return {Promise}
- */
- function checkOptimizeFileError(config, input, output, error) {
- if (error.code == 'EISDIR') {
- return optimizeFolder(config, input, output);
- } else if (error.code == 'ENOENT') {
- return Promise.reject(new Error(`Error: no such file or directory '${error.path}'.`));
- }
- return Promise.reject(error);
- }
- /**
- * Check for saving file error. If the output is a dir, then write file there.
- * @param {string} input
- * @param {string} output
- * @param {string} data
- * @param {Error} error
- * @return {Promise}
- */
- function checkWriteFileError(input, output, data, error) {
- if (error.code == 'EISDIR' && input) {
- return writeFile(PATH.resolve(output, PATH.basename(input)), data, 'utf8');
- } else {
- return Promise.reject(error);
- }
- }
- /**
- * Show list of available plugins with short description.
- */
- function showAvailablePlugins() {
- console.log('Currently available plugins:');
- // Flatten an array of plugins grouped per type, sort and write output
- var list = [].concat.apply([], new SVGO().config.plugins)
- .sort((a, b) => a.name.localeCompare(b.name))
- .map(plugin => ` [ ${chalk.green(plugin.name)} ] ${plugin.description}`)
- .join('\n');
- console.log(list);
- }
- /**
- * Write an error and exit.
- * @param {Error} error
- * @return {Promise} a promise for running tests
- */
- function printErrorAndExit(error) {
- console.error(chalk.red(error));
- process.exit(1);
- return Promise.reject(error); // for tests
- }
|