coa.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. 'use strict';
  2. const fs = require('fs');
  3. const path = require('path');
  4. const colors = require('picocolors');
  5. const { loadConfig, optimize } = require('../svgo-node.js');
  6. const pluginsMap = require('../../plugins/plugins.js');
  7. const PKG = require('../../package.json');
  8. const { encodeSVGDatauri, decodeSVGDatauri } = require('./tools.js');
  9. const regSVGFile = /\.svg$/i;
  10. /**
  11. * Synchronously check if path is a directory. Tolerant to errors like ENOENT.
  12. * @param {string} path
  13. */
  14. function checkIsDir(path) {
  15. try {
  16. return fs.lstatSync(path).isDirectory();
  17. } catch (e) {
  18. return false;
  19. }
  20. }
  21. module.exports = function makeProgram(program) {
  22. program
  23. .name(PKG.name)
  24. .description(PKG.description, {
  25. INPUT: 'Alias to --input',
  26. })
  27. .version(PKG.version, '-v, --version')
  28. .arguments('[INPUT...]')
  29. .option('-i, --input <INPUT...>', 'Input files, "-" for STDIN')
  30. .option('-s, --string <STRING>', 'Input SVG data string')
  31. .option(
  32. '-f, --folder <FOLDER>',
  33. 'Input folder, optimize and rewrite all *.svg files'
  34. )
  35. .option(
  36. '-o, --output <OUTPUT...>',
  37. 'Output file or folder (by default the same as the input), "-" for STDOUT'
  38. )
  39. .option(
  40. '-p, --precision <INTEGER>',
  41. 'Set number of digits in the fractional part, overrides plugins params'
  42. )
  43. .option('--config <CONFIG>', 'Custom config file, only .js is supported')
  44. .option(
  45. '--datauri <FORMAT>',
  46. 'Output as Data URI string (base64), URI encoded (enc) or unencoded (unenc)'
  47. )
  48. .option(
  49. '--multipass',
  50. 'Pass over SVGs multiple times to ensure all optimizations are applied'
  51. )
  52. .option('--pretty', 'Make SVG pretty printed')
  53. .option('--indent <INTEGER>', 'Indent number when pretty printing SVGs')
  54. .option(
  55. '--eol <EOL>',
  56. 'Line break to use when outputting SVG: lf, crlf. If unspecified, uses platform default.'
  57. )
  58. .option('--final-newline', 'Ensure SVG ends with a line break')
  59. .option(
  60. '-r, --recursive',
  61. "Use with '--folder'. Optimizes *.svg files in folders recursively."
  62. )
  63. .option(
  64. '--exclude <PATTERN...>',
  65. "Use with '--folder'. Exclude files matching regular expression pattern."
  66. )
  67. .option(
  68. '-q, --quiet',
  69. 'Only output error messages, not regular status messages'
  70. )
  71. .option('--show-plugins', 'Show available plugins and exit')
  72. // used by picocolors internally
  73. .option('--no-color', 'Output plain text without color')
  74. .action(action);
  75. };
  76. async function action(args, opts, command) {
  77. var input = opts.input || args;
  78. var output = opts.output;
  79. var config = {};
  80. if (opts.precision != null) {
  81. const number = Number.parseInt(opts.precision, 10);
  82. if (Number.isNaN(number)) {
  83. console.error(
  84. "error: option '-p, --precision' argument must be an integer number"
  85. );
  86. process.exit(1);
  87. } else {
  88. opts.precision = number;
  89. }
  90. }
  91. if (opts.datauri != null) {
  92. if (
  93. opts.datauri !== 'base64' &&
  94. opts.datauri !== 'enc' &&
  95. opts.datauri !== 'unenc'
  96. ) {
  97. console.error(
  98. "error: option '--datauri' must have one of the following values: 'base64', 'enc' or 'unenc'"
  99. );
  100. process.exit(1);
  101. }
  102. }
  103. if (opts.indent != null) {
  104. const number = Number.parseInt(opts.indent, 10);
  105. if (Number.isNaN(number)) {
  106. console.error(
  107. "error: option '--indent' argument must be an integer number"
  108. );
  109. process.exit(1);
  110. } else {
  111. opts.indent = number;
  112. }
  113. }
  114. if (opts.eol != null && opts.eol !== 'lf' && opts.eol !== 'crlf') {
  115. console.error(
  116. "error: option '--eol' must have one of the following values: 'lf' or 'crlf'"
  117. );
  118. process.exit(1);
  119. }
  120. // --show-plugins
  121. if (opts.showPlugins) {
  122. showAvailablePlugins();
  123. return;
  124. }
  125. // w/o anything
  126. if (
  127. (input.length === 0 || input[0] === '-') &&
  128. !opts.string &&
  129. !opts.stdin &&
  130. !opts.folder &&
  131. process.stdin.isTTY === true
  132. ) {
  133. return command.help();
  134. }
  135. if (
  136. typeof process == 'object' &&
  137. process.versions &&
  138. process.versions.node &&
  139. PKG &&
  140. PKG.engines.node
  141. ) {
  142. var nodeVersion = String(PKG.engines.node).match(/\d*(\.\d+)*/)[0];
  143. if (parseFloat(process.versions.node) < parseFloat(nodeVersion)) {
  144. throw Error(
  145. `${PKG.name} requires Node.js version ${nodeVersion} or higher.`
  146. );
  147. }
  148. }
  149. // --config
  150. const loadedConfig = await loadConfig(opts.config);
  151. if (loadedConfig != null) {
  152. config = loadedConfig;
  153. }
  154. // --quiet
  155. if (opts.quiet) {
  156. config.quiet = opts.quiet;
  157. }
  158. // --recursive
  159. if (opts.recursive) {
  160. config.recursive = opts.recursive;
  161. }
  162. // --exclude
  163. config.exclude = opts.exclude
  164. ? opts.exclude.map((pattern) => RegExp(pattern))
  165. : [];
  166. // --precision
  167. if (opts.precision != null) {
  168. var precision = Math.min(Math.max(0, opts.precision), 20);
  169. config.floatPrecision = precision;
  170. }
  171. // --multipass
  172. if (opts.multipass) {
  173. config.multipass = true;
  174. }
  175. // --pretty
  176. if (opts.pretty) {
  177. config.js2svg = config.js2svg || {};
  178. config.js2svg.pretty = true;
  179. if (opts.indent != null) {
  180. config.js2svg.indent = opts.indent;
  181. }
  182. }
  183. // --eol
  184. if (opts.eol) {
  185. config.js2svg = config.js2svg || {};
  186. config.js2svg.eol = opts.eol;
  187. }
  188. // --final-newline
  189. if (opts.finalNewline) {
  190. config.js2svg = config.js2svg || {};
  191. config.js2svg.finalNewline = true;
  192. }
  193. // --output
  194. if (output) {
  195. if (input.length && input[0] != '-') {
  196. if (output.length == 1 && checkIsDir(output[0])) {
  197. var dir = output[0];
  198. for (var i = 0; i < input.length; i++) {
  199. output[i] = checkIsDir(input[i])
  200. ? input[i]
  201. : path.resolve(dir, path.basename(input[i]));
  202. }
  203. } else if (output.length < input.length) {
  204. output = output.concat(input.slice(output.length));
  205. }
  206. }
  207. } else if (input.length) {
  208. output = input;
  209. } else if (opts.string) {
  210. output = '-';
  211. }
  212. if (opts.datauri) {
  213. config.datauri = opts.datauri;
  214. }
  215. // --folder
  216. if (opts.folder) {
  217. var ouputFolder = (output && output[0]) || opts.folder;
  218. await optimizeFolder(config, opts.folder, ouputFolder);
  219. }
  220. // --input
  221. if (input.length !== 0) {
  222. // STDIN
  223. if (input[0] === '-') {
  224. return new Promise((resolve, reject) => {
  225. var data = '',
  226. file = output[0];
  227. process.stdin
  228. .on('data', (chunk) => (data += chunk))
  229. .once('end', () =>
  230. processSVGData(config, { input: 'string' }, data, file).then(
  231. resolve,
  232. reject
  233. )
  234. );
  235. });
  236. // file
  237. } else {
  238. await Promise.all(
  239. input.map((file, n) => optimizeFile(config, file, output[n]))
  240. );
  241. }
  242. // --string
  243. } else if (opts.string) {
  244. var data = decodeSVGDatauri(opts.string);
  245. return processSVGData(config, { input: 'string' }, data, output[0]);
  246. }
  247. }
  248. /**
  249. * Optimize SVG files in a directory.
  250. * @param {Object} config options
  251. * @param {string} dir input directory
  252. * @param {string} output output directory
  253. * @return {Promise}
  254. */
  255. function optimizeFolder(config, dir, output) {
  256. if (!config.quiet) {
  257. console.log(`Processing directory '${dir}':\n`);
  258. }
  259. return fs.promises
  260. .readdir(dir)
  261. .then((files) => processDirectory(config, dir, files, output));
  262. }
  263. /**
  264. * Process given files, take only SVG.
  265. * @param {Object} config options
  266. * @param {string} dir input directory
  267. * @param {Array} files list of file names in the directory
  268. * @param {string} output output directory
  269. * @return {Promise}
  270. */
  271. function processDirectory(config, dir, files, output) {
  272. // take only *.svg files, recursively if necessary
  273. var svgFilesDescriptions = getFilesDescriptions(config, dir, files, output);
  274. return svgFilesDescriptions.length
  275. ? Promise.all(
  276. svgFilesDescriptions.map((fileDescription) =>
  277. optimizeFile(
  278. config,
  279. fileDescription.inputPath,
  280. fileDescription.outputPath
  281. )
  282. )
  283. )
  284. : Promise.reject(
  285. new Error(`No SVG files have been found in '${dir}' directory.`)
  286. );
  287. }
  288. /**
  289. * Get svg files descriptions
  290. * @param {Object} config options
  291. * @param {string} dir input directory
  292. * @param {Array} files list of file names in the directory
  293. * @param {string} output output directory
  294. * @return {Array}
  295. */
  296. function getFilesDescriptions(config, dir, files, output) {
  297. const filesInThisFolder = files
  298. .filter(
  299. (name) =>
  300. regSVGFile.test(name) &&
  301. !config.exclude.some((regExclude) => regExclude.test(name))
  302. )
  303. .map((name) => ({
  304. inputPath: path.resolve(dir, name),
  305. outputPath: path.resolve(output, name),
  306. }));
  307. return config.recursive
  308. ? [].concat(
  309. filesInThisFolder,
  310. files
  311. .filter((name) => checkIsDir(path.resolve(dir, name)))
  312. .map((subFolderName) => {
  313. const subFolderPath = path.resolve(dir, subFolderName);
  314. const subFolderFiles = fs.readdirSync(subFolderPath);
  315. const subFolderOutput = path.resolve(output, subFolderName);
  316. return getFilesDescriptions(
  317. config,
  318. subFolderPath,
  319. subFolderFiles,
  320. subFolderOutput
  321. );
  322. })
  323. .reduce((a, b) => [].concat(a, b), [])
  324. )
  325. : filesInThisFolder;
  326. }
  327. /**
  328. * Read SVG file and pass to processing.
  329. * @param {Object} config options
  330. * @param {string} file
  331. * @param {string} output
  332. * @return {Promise}
  333. */
  334. function optimizeFile(config, file, output) {
  335. return fs.promises.readFile(file, 'utf8').then(
  336. (data) =>
  337. processSVGData(config, { input: 'file', path: file }, data, output, file),
  338. (error) => checkOptimizeFileError(config, file, output, error)
  339. );
  340. }
  341. /**
  342. * Optimize SVG data.
  343. * @param {Object} config options
  344. * @param {string} data SVG content to optimize
  345. * @param {string} output where to write optimized file
  346. * @param {string} [input] input file name (being used if output is a directory)
  347. * @return {Promise}
  348. */
  349. function processSVGData(config, info, data, output, input) {
  350. var startTime = Date.now(),
  351. prevFileSize = Buffer.byteLength(data, 'utf8');
  352. const result = optimize(data, { ...config, ...info });
  353. if (result.modernError) {
  354. console.error(colors.red(result.modernError.toString()));
  355. process.exit(1);
  356. }
  357. if (config.datauri) {
  358. result.data = encodeSVGDatauri(result.data, config.datauri);
  359. }
  360. var resultFileSize = Buffer.byteLength(result.data, 'utf8'),
  361. processingTime = Date.now() - startTime;
  362. return writeOutput(input, output, result.data).then(
  363. function () {
  364. if (!config.quiet && output != '-') {
  365. if (input) {
  366. console.log(`\n${path.basename(input)}:`);
  367. }
  368. printTimeInfo(processingTime);
  369. printProfitInfo(prevFileSize, resultFileSize);
  370. }
  371. },
  372. (error) =>
  373. Promise.reject(
  374. new Error(
  375. error.code === 'ENOTDIR'
  376. ? `Error: output '${output}' is not a directory.`
  377. : error
  378. )
  379. )
  380. );
  381. }
  382. /**
  383. * Write result of an optimization.
  384. * @param {string} input
  385. * @param {string} output output file name. '-' for stdout
  386. * @param {string} data data to write
  387. * @return {Promise}
  388. */
  389. function writeOutput(input, output, data) {
  390. if (output == '-') {
  391. console.log(data);
  392. return Promise.resolve();
  393. }
  394. fs.mkdirSync(path.dirname(output), { recursive: true });
  395. return fs.promises
  396. .writeFile(output, data, 'utf8')
  397. .catch((error) => checkWriteFileError(input, output, data, error));
  398. }
  399. /**
  400. * Write a time taken by optimization.
  401. * @param {number} time time in milliseconds.
  402. */
  403. function printTimeInfo(time) {
  404. console.log(`Done in ${time} ms!`);
  405. }
  406. /**
  407. * Write optimizing information in human readable format.
  408. * @param {number} inBytes size before optimization.
  409. * @param {number} outBytes size after optimization.
  410. */
  411. function printProfitInfo(inBytes, outBytes) {
  412. var profitPercents = 100 - (outBytes * 100) / inBytes;
  413. console.log(
  414. Math.round((inBytes / 1024) * 1000) / 1000 +
  415. ' KiB' +
  416. (profitPercents < 0 ? ' + ' : ' - ') +
  417. colors.green(Math.abs(Math.round(profitPercents * 10) / 10) + '%') +
  418. ' = ' +
  419. Math.round((outBytes / 1024) * 1000) / 1000 +
  420. ' KiB'
  421. );
  422. }
  423. /**
  424. * Check for errors, if it's a dir optimize the dir.
  425. * @param {Object} config
  426. * @param {string} input
  427. * @param {string} output
  428. * @param {Error} error
  429. * @return {Promise}
  430. */
  431. function checkOptimizeFileError(config, input, output, error) {
  432. if (error.code == 'EISDIR') {
  433. return optimizeFolder(config, input, output);
  434. } else if (error.code == 'ENOENT') {
  435. return Promise.reject(
  436. new Error(`Error: no such file or directory '${error.path}'.`)
  437. );
  438. }
  439. return Promise.reject(error);
  440. }
  441. /**
  442. * Check for saving file error. If the output is a dir, then write file there.
  443. * @param {string} input
  444. * @param {string} output
  445. * @param {string} data
  446. * @param {Error} error
  447. * @return {Promise}
  448. */
  449. function checkWriteFileError(input, output, data, error) {
  450. if (error.code == 'EISDIR' && input) {
  451. return fs.promises.writeFile(
  452. path.resolve(output, path.basename(input)),
  453. data,
  454. 'utf8'
  455. );
  456. } else {
  457. return Promise.reject(error);
  458. }
  459. }
  460. /**
  461. * Show list of available plugins with short description.
  462. */
  463. function showAvailablePlugins() {
  464. const list = Object.entries(pluginsMap)
  465. .sort(([a], [b]) => a.localeCompare(b))
  466. .map(([name, plugin]) => ` [ ${colors.green(name)} ] ${plugin.description}`)
  467. .join('\n');
  468. console.log('Currently available plugins:\n' + list);
  469. }
  470. module.exports.checkIsDir = checkIsDir;