4ae118a62a1b99f2548a45a2e1f899c9f09461b50b33041618ba49d01a9eb38fd358586720cf35bdf42e5458f40068e7aa133e3082740297be526fdb93b05b 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. /* jshint quotmark: false */
  2. 'use strict';
  3. var FS = require('fs'),
  4. PATH = require('path'),
  5. chalk = require('chalk'),
  6. mkdirp = require('mkdirp'),
  7. promisify = require('util.promisify'),
  8. readdir = promisify(FS.readdir),
  9. readFile = promisify(FS.readFile),
  10. writeFile = promisify(FS.writeFile),
  11. SVGO = require('../svgo.js'),
  12. YAML = require('js-yaml'),
  13. PKG = require('../../package.json'),
  14. encodeSVGDatauri = require('./tools.js').encodeSVGDatauri,
  15. decodeSVGDatauri = require('./tools.js').decodeSVGDatauri,
  16. checkIsDir = require('./tools.js').checkIsDir,
  17. regSVGFile = /\.svg$/,
  18. noop = () => {},
  19. svgo;
  20. /**
  21. * Command-Option-Argument.
  22. *
  23. * @see https://github.com/veged/coa
  24. */
  25. module.exports = require('coa').Cmd()
  26. .helpful()
  27. .name(PKG.name)
  28. .title(PKG.description)
  29. .opt()
  30. .name('version').title('Version')
  31. .short('v').long('version')
  32. .only()
  33. .flag()
  34. .act(function() {
  35. // output the version to stdout instead of stderr if returned
  36. process.stdout.write(PKG.version + '\n');
  37. // coa will run `.toString` on the returned value and send it to stderr
  38. return '';
  39. })
  40. .end()
  41. .opt()
  42. .name('input').title('Input file, "-" for STDIN')
  43. .short('i').long('input')
  44. .arr()
  45. .val(function(val) {
  46. return val || this.reject("Option '--input' must have a value.");
  47. })
  48. .end()
  49. .opt()
  50. .name('string').title('Input SVG data string')
  51. .short('s').long('string')
  52. .end()
  53. .opt()
  54. .name('folder').title('Input folder, optimize and rewrite all *.svg files')
  55. .short('f').long('folder')
  56. .val(function(val) {
  57. return val || this.reject("Option '--folder' must have a value.");
  58. })
  59. .end()
  60. .opt()
  61. .name('output').title('Output file or folder (by default the same as the input), "-" for STDOUT')
  62. .short('o').long('output')
  63. .arr()
  64. .val(function(val) {
  65. return val || this.reject("Option '--output' must have a value.");
  66. })
  67. .end()
  68. .opt()
  69. .name('precision').title('Set number of digits in the fractional part, overrides plugins params')
  70. .short('p').long('precision')
  71. .val(function(val) {
  72. return !isNaN(val) ? val : this.reject("Option '--precision' must be an integer number");
  73. })
  74. .end()
  75. .opt()
  76. .name('config').title('Config file or JSON string to extend or replace default')
  77. .long('config')
  78. .val(function(val) {
  79. return val || this.reject("Option '--config' must have a value.");
  80. })
  81. .end()
  82. .opt()
  83. .name('disable').title('Disable plugin by name, "--disable={PLUGIN1,PLUGIN2}" for multiple plugins (*nix)')
  84. .long('disable')
  85. .arr()
  86. .val(function(val) {
  87. return val || this.reject("Option '--disable' must have a value.");
  88. })
  89. .end()
  90. .opt()
  91. .name('enable').title('Enable plugin by name, "--enable={PLUGIN3,PLUGIN4}" for multiple plugins (*nix)')
  92. .long('enable')
  93. .arr()
  94. .val(function(val) {
  95. return val || this.reject("Option '--enable' must have a value.");
  96. })
  97. .end()
  98. .opt()
  99. .name('datauri').title('Output as Data URI string (base64, URI encoded or unencoded)')
  100. .long('datauri')
  101. .val(function(val) {
  102. return val || this.reject("Option '--datauri' must have one of the following values: 'base64', 'enc' or 'unenc'");
  103. })
  104. .end()
  105. .opt()
  106. .name('multipass').title('Pass over SVGs multiple times to ensure all optimizations are applied')
  107. .long('multipass')
  108. .flag()
  109. .end()
  110. .opt()
  111. .name('pretty').title('Make SVG pretty printed')
  112. .long('pretty')
  113. .flag()
  114. .end()
  115. .opt()
  116. .name('indent').title('Indent number when pretty printing SVGs')
  117. .long('indent')
  118. .val(function(val) {
  119. return !isNaN(val) ? val : this.reject("Option '--indent' must be an integer number");
  120. })
  121. .end()
  122. .opt()
  123. .name('recursive').title('Use with \'-f\'. Optimizes *.svg files in folders recursively.')
  124. .short('r').long('recursive')
  125. .flag()
  126. .end()
  127. .opt()
  128. .name('quiet').title('Only output error messages, not regular status messages')
  129. .short('q').long('quiet')
  130. .flag()
  131. .end()
  132. .opt()
  133. .name('show-plugins').title('Show available plugins and exit')
  134. .long('show-plugins')
  135. .flag()
  136. .end()
  137. .arg()
  138. .name('input').title('Alias to --input')
  139. .arr()
  140. .end()
  141. .act(function(opts, args) {
  142. var input = opts.input || args.input,
  143. output = opts.output,
  144. config = {};
  145. // --show-plugins
  146. if (opts['show-plugins']) {
  147. showAvailablePlugins();
  148. return;
  149. }
  150. // w/o anything
  151. if (
  152. (!input || input[0] === '-') &&
  153. !opts.string &&
  154. !opts.stdin &&
  155. !opts.folder &&
  156. process.stdin.isTTY === true
  157. ) return this.usage();
  158. if (typeof process == 'object' && process.versions && process.versions.node && PKG && PKG.engines.node) {
  159. var nodeVersion = String(PKG.engines.node).match(/\d*(\.\d+)*/)[0];
  160. if (parseFloat(process.versions.node) < parseFloat(nodeVersion)) {
  161. return printErrorAndExit(`Error: ${PKG.name} requires Node.js version ${nodeVersion} or higher.`);
  162. }
  163. }
  164. // --config
  165. if (opts.config) {
  166. // string
  167. if (opts.config.charAt(0) === '{') {
  168. try {
  169. config = JSON.parse(opts.config);
  170. } catch (e) {
  171. return printErrorAndExit(`Error: Couldn't parse config JSON.\n${String(e)}`);
  172. }
  173. // external file
  174. } else {
  175. var configPath = PATH.resolve(opts.config),
  176. configData;
  177. try {
  178. // require() adds some weird output on YML files
  179. configData = FS.readFileSync(configPath, 'utf8');
  180. config = JSON.parse(configData);
  181. } catch (err) {
  182. if (err.code === 'ENOENT') {
  183. return printErrorAndExit(`Error: couldn't find config file '${opts.config}'.`);
  184. } else if (err.code === 'EISDIR') {
  185. return printErrorAndExit(`Error: directory '${opts.config}' is not a config file.`);
  186. }
  187. config = YAML.safeLoad(configData);
  188. config.__DIR = PATH.dirname(configPath); // will use it to resolve custom plugins defined via path
  189. if (!config || Array.isArray(config)) {
  190. return printErrorAndExit(`Error: invalid config file '${opts.config}'.`);
  191. }
  192. }
  193. }
  194. }
  195. // --quiet
  196. if (opts.quiet) {
  197. config.quiet = opts.quiet;
  198. }
  199. // --recursive
  200. if (opts.recursive) {
  201. config.recursive = opts.recursive;
  202. }
  203. // --precision
  204. if (opts.precision) {
  205. var precision = Math.min(Math.max(0, parseInt(opts.precision)), 20);
  206. if (!isNaN(precision)) {
  207. config.floatPrecision = precision;
  208. }
  209. }
  210. // --disable
  211. if (opts.disable) {
  212. changePluginsState(opts.disable, false, config);
  213. }
  214. // --enable
  215. if (opts.enable) {
  216. changePluginsState(opts.enable, true, config);
  217. }
  218. // --multipass
  219. if (opts.multipass) {
  220. config.multipass = true;
  221. }
  222. // --pretty
  223. if (opts.pretty) {
  224. config.js2svg = config.js2svg || {};
  225. config.js2svg.pretty = true;
  226. var indent;
  227. if (opts.indent && !isNaN(indent = parseInt(opts.indent))) {
  228. config.js2svg.indent = indent;
  229. }
  230. }
  231. svgo = new SVGO(config);
  232. // --output
  233. if (output) {
  234. if (input && input[0] != '-') {
  235. if (output.length == 1 && checkIsDir(output[0])) {
  236. var dir = output[0];
  237. for (var i = 0; i < input.length; i++) {
  238. output[i] = checkIsDir(input[i]) ? input[i] : PATH.resolve(dir, PATH.basename(input[i]));
  239. }
  240. } else if (output.length < input.length) {
  241. output = output.concat(input.slice(output.length));
  242. }
  243. }
  244. } else if (input) {
  245. output = input;
  246. } else if (opts.string) {
  247. output = '-';
  248. }
  249. if (opts.datauri) {
  250. config.datauri = opts.datauri;
  251. }
  252. // --folder
  253. if (opts.folder) {
  254. var ouputFolder = output && output[0] || opts.folder;
  255. return optimizeFolder(config, opts.folder, ouputFolder).then(noop, printErrorAndExit);
  256. }
  257. // --input
  258. if (input) {
  259. // STDIN
  260. if (input[0] === '-') {
  261. return new Promise((resolve, reject) => {
  262. var data = '',
  263. file = output[0];
  264. process.stdin
  265. .on('data', chunk => data += chunk)
  266. .once('end', () => processSVGData(config, {input: 'string'}, data, file).then(resolve, reject));
  267. });
  268. // file
  269. } else {
  270. return Promise.all(input.map((file, n) => optimizeFile(config, file, output[n])))
  271. .then(noop, printErrorAndExit);
  272. }
  273. // --string
  274. } else if (opts.string) {
  275. var data = decodeSVGDatauri(opts.string);
  276. return processSVGData(config, {input: 'string'}, data, output[0]);
  277. }
  278. });
  279. /**
  280. * Change plugins state by names array.
  281. *
  282. * @param {Array} names plugins names
  283. * @param {Boolean} state active state
  284. * @param {Object} config original config
  285. * @return {Object} changed config
  286. */
  287. function changePluginsState(names, state, config) {
  288. names.forEach(flattenPluginsCbk);
  289. // extend config
  290. if (config.plugins) {
  291. for (var name of names) {
  292. var matched = false,
  293. key;
  294. for (var plugin of config.plugins) {
  295. // get plugin name
  296. if (typeof plugin === 'object') {
  297. key = Object.keys(plugin)[0];
  298. } else {
  299. key = plugin;
  300. }
  301. // if there is such a plugin name
  302. if (key === name) {
  303. // don't replace plugin's params with true
  304. if (typeof plugin[key] !== 'object' || !state) {
  305. plugin[key] = state;
  306. }
  307. // mark it as matched
  308. matched = true;
  309. }
  310. }
  311. // if not matched and current config is not full
  312. if (!matched && !config.full) {
  313. // push new plugin Object
  314. config.plugins.push({ [name]: state });
  315. matched = true;
  316. }
  317. }
  318. // just push
  319. } else {
  320. config.plugins = names.map(name => ({ [name]: state }));
  321. }
  322. return config;
  323. }
  324. /**
  325. * Flatten an array of plugins by invoking this callback on each element
  326. * whose value may be a comma separated list of plugins.
  327. *
  328. * @param {String} name Plugin name
  329. * @param {Number} index Plugin index
  330. * @param {Array} names Plugins being traversed
  331. */
  332. function flattenPluginsCbk(name, index, names)
  333. {
  334. var split = name.split(',');
  335. if(split.length > 1) {
  336. names[index] = split.shift();
  337. names.push.apply(names, split);
  338. }
  339. }
  340. /**
  341. * Optimize SVG files in a directory.
  342. * @param {Object} config options
  343. * @param {string} dir input directory
  344. * @param {string} output output directory
  345. * @return {Promise}
  346. */
  347. function optimizeFolder(config, dir, output) {
  348. if (!config.quiet) {
  349. console.log(`Processing directory '${dir}':\n`);
  350. }
  351. return readdir(dir).then(files => processDirectory(config, dir, files, output));
  352. }
  353. /**
  354. * Process given files, take only SVG.
  355. * @param {Object} config options
  356. * @param {string} dir input directory
  357. * @param {Array} files list of file names in the directory
  358. * @param {string} output output directory
  359. * @return {Promise}
  360. */
  361. function processDirectory(config, dir, files, output) {
  362. // take only *.svg files, recursively if necessary
  363. var svgFilesDescriptions = getFilesDescriptions(config, dir, files, output);
  364. return svgFilesDescriptions.length ?
  365. Promise.all(svgFilesDescriptions.map(fileDescription => optimizeFile(config, fileDescription.inputPath, fileDescription.outputPath))) :
  366. Promise.reject(new Error(`No SVG files have been found in '${dir}' directory.`));
  367. }
  368. /**
  369. * Get svg files descriptions
  370. * @param {Object} config options
  371. * @param {string} dir input directory
  372. * @param {Array} files list of file names in the directory
  373. * @param {string} output output directory
  374. * @return {Array}
  375. */
  376. function getFilesDescriptions(config, dir, files, output) {
  377. const filesInThisFolder = files
  378. .filter(name => regSVGFile.test(name))
  379. .map(name => ({
  380. inputPath: PATH.resolve(dir, name),
  381. outputPath: PATH.resolve(output, name),
  382. }));
  383. return config.recursive ?
  384. [].concat(
  385. filesInThisFolder,
  386. files
  387. .filter(name => checkIsDir(PATH.resolve(dir, name)))
  388. .map(subFolderName => {
  389. const subFolderPath = PATH.resolve(dir, subFolderName);
  390. const subFolderFiles = FS.readdirSync(subFolderPath);
  391. const subFolderOutput = PATH.resolve(output, subFolderName);
  392. return getFilesDescriptions(config, subFolderPath, subFolderFiles, subFolderOutput);
  393. })
  394. .reduce((a, b) => [].concat(a, b), [])
  395. ) :
  396. filesInThisFolder;
  397. }
  398. /**
  399. * Read SVG file and pass to processing.
  400. * @param {Object} config options
  401. * @param {string} file
  402. * @param {string} output
  403. * @return {Promise}
  404. */
  405. function optimizeFile(config, file, output) {
  406. return readFile(file, 'utf8').then(
  407. data => processSVGData(config, {input: 'file', path: file}, data, output, file),
  408. error => checkOptimizeFileError(config, file, output, error)
  409. );
  410. }
  411. /**
  412. * Optimize SVG data.
  413. * @param {Object} config options
  414. * @param {string} data SVG content to optimize
  415. * @param {string} output where to write optimized file
  416. * @param {string} [input] input file name (being used if output is a directory)
  417. * @return {Promise}
  418. */
  419. function processSVGData(config, info, data, output, input) {
  420. var startTime = Date.now(),
  421. prevFileSize = Buffer.byteLength(data, 'utf8');
  422. return svgo.optimize(data, info).then(function(result) {
  423. if (config.datauri) {
  424. result.data = encodeSVGDatauri(result.data, config.datauri);
  425. }
  426. var resultFileSize = Buffer.byteLength(result.data, 'utf8'),
  427. processingTime = Date.now() - startTime;
  428. return writeOutput(input, output, result.data).then(function() {
  429. if (!config.quiet && output != '-') {
  430. if (input) {
  431. console.log(`\n${PATH.basename(input)}:`);
  432. }
  433. printTimeInfo(processingTime);
  434. printProfitInfo(prevFileSize, resultFileSize);
  435. }
  436. },
  437. error => Promise.reject(new Error(error.code === 'ENOTDIR' ? `Error: output '${output}' is not a directory.` : error)));
  438. });
  439. }
  440. /**
  441. * Write result of an optimization.
  442. * @param {string} input
  443. * @param {string} output output file name. '-' for stdout
  444. * @param {string} data data to write
  445. * @return {Promise}
  446. */
  447. function writeOutput(input, output, data) {
  448. if (output == '-') {
  449. console.log(data);
  450. return Promise.resolve();
  451. }
  452. mkdirp.sync(PATH.dirname(output));
  453. return writeFile(output, data, 'utf8').catch(error => checkWriteFileError(input, output, data, error));
  454. }
  455. /**
  456. * Write a time taken by optimization.
  457. * @param {number} time time in milliseconds.
  458. */
  459. function printTimeInfo(time) {
  460. console.log(`Done in ${time} ms!`);
  461. }
  462. /**
  463. * Write optimizing information in human readable format.
  464. * @param {number} inBytes size before optimization.
  465. * @param {number} outBytes size after optimization.
  466. */
  467. function printProfitInfo(inBytes, outBytes) {
  468. var profitPercents = 100 - outBytes * 100 / inBytes;
  469. console.log(
  470. (Math.round((inBytes / 1024) * 1000) / 1000) + ' KiB' +
  471. (profitPercents < 0 ? ' + ' : ' - ') +
  472. chalk.green(Math.abs((Math.round(profitPercents * 10) / 10)) + '%') + ' = ' +
  473. (Math.round((outBytes / 1024) * 1000) / 1000) + ' KiB'
  474. );
  475. }
  476. /**
  477. * Check for errors, if it's a dir optimize the dir.
  478. * @param {Object} config
  479. * @param {string} input
  480. * @param {string} output
  481. * @param {Error} error
  482. * @return {Promise}
  483. */
  484. function checkOptimizeFileError(config, input, output, error) {
  485. if (error.code == 'EISDIR') {
  486. return optimizeFolder(config, input, output);
  487. } else if (error.code == 'ENOENT') {
  488. return Promise.reject(new Error(`Error: no such file or directory '${error.path}'.`));
  489. }
  490. return Promise.reject(error);
  491. }
  492. /**
  493. * Check for saving file error. If the output is a dir, then write file there.
  494. * @param {string} input
  495. * @param {string} output
  496. * @param {string} data
  497. * @param {Error} error
  498. * @return {Promise}
  499. */
  500. function checkWriteFileError(input, output, data, error) {
  501. if (error.code == 'EISDIR' && input) {
  502. return writeFile(PATH.resolve(output, PATH.basename(input)), data, 'utf8');
  503. } else {
  504. return Promise.reject(error);
  505. }
  506. }
  507. /**
  508. * Show list of available plugins with short description.
  509. */
  510. function showAvailablePlugins() {
  511. console.log('Currently available plugins:');
  512. // Flatten an array of plugins grouped per type, sort and write output
  513. var list = [].concat.apply([], new SVGO().config.plugins)
  514. .sort((a, b) => a.name.localeCompare(b.name))
  515. .map(plugin => ` [ ${chalk.green(plugin.name)} ] ${plugin.description}`)
  516. .join('\n');
  517. console.log(list);
  518. }
  519. /**
  520. * Write an error and exit.
  521. * @param {Error} error
  522. * @return {Promise} a promise for running tests
  523. */
  524. function printErrorAndExit(error) {
  525. console.error(chalk.red(error));
  526. process.exit(1);
  527. return Promise.reject(error); // for tests
  528. }