tauricon.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. 'use strict'
  2. /**
  3. * This is a module that takes an original image and resizes
  4. * it to common icon sizes and will put them in a folder.
  5. * It will retain transparency and can make special file
  6. * types. You can control the settings.
  7. *
  8. * @module tauricon
  9. * @exports tauricon
  10. * @author Daniel Thompson-Yvetot
  11. * @license MIT
  12. */
  13. const path = require('path')
  14. const sharp = require('sharp')
  15. const imagemin = require('imagemin')
  16. const pngquant = require('imagemin-pngquant')
  17. const optipng = require('imagemin-optipng')
  18. const zopfli = require('imagemin-zopfli')
  19. const png2icons = require('png2icons')
  20. const readChunk = require('read-chunk')
  21. const isPng = require('is-png')
  22. const logger = require('./logger')
  23. const log = logger('app:spawn')
  24. const warn = logger('app:spawn', 'red')
  25. const settings = require('./tauricon.config.js')
  26. let image = false
  27. let spinnerInterval = false
  28. const {
  29. access,
  30. writeFileSync,
  31. ensureDir,
  32. ensureFileSync
  33. } = require('fs-extra')
  34. const exists = async function (file) {
  35. try {
  36. await access(file)
  37. return true
  38. } catch (err) {
  39. return false
  40. }
  41. }
  42. /**
  43. * This is the first call that attempts to memoize the sharp(src).
  44. * If the source image cannot be found or if it is not a png, it
  45. * is a failsafe that will exit or throw.
  46. *
  47. * @param {string} src - a folder to target
  48. * @exits {error} if not a png, if not an image
  49. */
  50. const checkSrc = async function (src) {
  51. if (image !== false) {
  52. return image
  53. } else {
  54. const srcExists = await exists(src)
  55. if (!srcExists) {
  56. image = false
  57. if (spinnerInterval) clearInterval(spinnerInterval)
  58. warn('[ERROR] Source image for tauricon not found')
  59. process.exit(1)
  60. } else {
  61. const buffer = await readChunk(src, 0, 8)
  62. if (isPng(buffer) === true) {
  63. return (image = sharp(src))
  64. } else {
  65. image = false
  66. if (spinnerInterval) clearInterval(spinnerInterval)
  67. warn('[ERROR] Source image for tauricon is not a png')
  68. process.exit(1)
  69. }
  70. }
  71. }
  72. }
  73. /**
  74. * Sort the folders in the current job for unique folders.
  75. *
  76. * @param {object} options - a subset of the settings
  77. * @returns {array} folders
  78. */
  79. const uniqueFolders = function (options) {
  80. let folders = []
  81. for (const type in options) {
  82. if (options[type].folder) {
  83. folders.push(options[type].folder)
  84. }
  85. }
  86. folders = folders.sort().filter((x, i, a) => !i || x !== a[i - 1])
  87. return folders
  88. }
  89. /**
  90. * Turn a hex color (like #212342) into r,g,b values
  91. *
  92. * @param {string} hex - hex colour
  93. * @returns {array} r,g,b
  94. */
  95. const hexToRgb = function (hex) {
  96. // https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb
  97. // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
  98. const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
  99. hex = hex.replace(shorthandRegex, function (m, r, g, b) {
  100. return r + r + g + g + b + b
  101. })
  102. const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
  103. return result
  104. ? {
  105. r: parseInt(result[1], 16),
  106. g: parseInt(result[2], 16),
  107. b: parseInt(result[3], 16)
  108. }
  109. : null
  110. }
  111. /**
  112. * validate image and directory
  113. * @param {string} src
  114. * @param {string} target
  115. * @returns {Promise<void>}
  116. */
  117. const validate = async function (src, target) {
  118. if (target !== undefined) {
  119. await ensureDir(target)
  120. }
  121. return checkSrc(src)
  122. }
  123. /**
  124. * Log progress in the command line
  125. *
  126. * @param {string} msg
  127. * @param {boolean} end
  128. */
  129. const progress = function (msg) {
  130. process.stdout.write(` ${msg} \r`)
  131. }
  132. /**
  133. * Create a spinner on the command line
  134. *
  135. * @example
  136. *
  137. * const spinnerInterval = spinner()
  138. * // later
  139. * clearInterval(spinnerInterval)
  140. * @returns {function} - the interval object
  141. */
  142. const spinner = function () {
  143. return setInterval(() => {
  144. process.stdout.write('/ \r')
  145. setTimeout(() => {
  146. process.stdout.write('- \r')
  147. setTimeout(() => {
  148. process.stdout.write('\\ \r')
  149. setTimeout(() => {
  150. process.stdout.write('| \r')
  151. }, 100)
  152. }, 100)
  153. }, 100)
  154. }, 500)
  155. }
  156. const tauricon = exports.tauricon = {
  157. validate: async function (src, target) {
  158. await validate(src, target)
  159. return typeof image === 'object'
  160. },
  161. version: function () {
  162. return require('../../package.json').version
  163. },
  164. /**
  165. *
  166. * @param {string} src
  167. * @param {string} target
  168. * @param {string} strategy
  169. * @param {object} options
  170. */
  171. make: async function (src, target, strategy, options) {
  172. const spinnerInterval = spinner()
  173. options = options || settings.options.tauri
  174. const valid = await this.validate(src, target)
  175. progress('Building Tauri icns and ico')
  176. await this.icns(src, target, options, strategy)
  177. progress('Building Tauri png icons')
  178. await this.build(src, target, options)
  179. if (strategy) {
  180. progress(`Minifying assets with ${strategy}`)
  181. await this.minify(target, options, strategy, 'batch')
  182. } else {
  183. log('no minify strategy')
  184. }
  185. progress('Tauricon Finished')
  186. clearInterval(spinnerInterval)
  187. return true
  188. },
  189. /**
  190. * Creates a set of images according to the subset of options it knows about.
  191. *
  192. * @param {string} src - image location
  193. * @param {string} target - where to drop the images
  194. * @param {object} options - js object that defines path and sizes
  195. */
  196. build: async function (src, target, options) {
  197. await this.validate(src, target)
  198. const sharpSrc = sharp(src) // creates the image object
  199. const buildify2 = async function (pvar) {
  200. try {
  201. const pngImage = sharpSrc.resize(pvar[1], pvar[1])
  202. if (pvar[2]) {
  203. const rgb = hexToRgb(options.background_color)
  204. pngImage.flatten({
  205. background: { r: rgb.r, g: rgb.g, b: rgb.b, alpha: 1 }
  206. })
  207. }
  208. pngImage.png()
  209. await pngImage.toFile(pvar[0])
  210. } catch (err) {
  211. warn(err)
  212. }
  213. }
  214. let output
  215. const folders = uniqueFolders(options)
  216. for (const n in folders) {
  217. // make the folders first
  218. ensureDir(`${target}${path.sep}${folders[n]}`)
  219. }
  220. for (const optionKey in options) {
  221. const option = options[optionKey]
  222. // chain up the transforms
  223. for (const sizeKey in option.sizes) {
  224. const size = option.sizes[sizeKey]
  225. if (!option.splash) {
  226. const dest = `${target}/${option.folder}`
  227. if (option.infix === true) {
  228. output = `${dest}${path.sep}${option.prefix}${size}x${size}${option.suffix}`
  229. } else {
  230. output = `${dest}${path.sep}${option.prefix}${option.suffix}`
  231. }
  232. const pvar = [output, size, option.background]
  233. await buildify2(pvar)
  234. }
  235. }
  236. }
  237. },
  238. /**
  239. * Creates a set of splash images (COMING SOON!!!)
  240. *
  241. * @param {string} src - icon location
  242. * @param {string} splashSrc - splashscreen location
  243. * @param {string} target - where to drop the images
  244. * @param {object} options - js object that defines path and sizes
  245. */
  246. splash: async function (src, splashSrc, target, options) {
  247. let output
  248. let block = false
  249. const rgb = hexToRgb(options.background_color)
  250. // three options
  251. // options: splashscreen_type [generate | overlay | pure]
  252. // - generate (icon + background color) DEFAULT
  253. // - overlay (icon + splashscreen)
  254. // - pure (only splashscreen)
  255. let sharpSrc
  256. if (splashSrc === src) {
  257. // prevent overlay or pure
  258. block = true
  259. }
  260. if (block === true || options.splashscreen_type === 'generate') {
  261. await this.validate(src, target)
  262. if (!image) {
  263. process.exit(1)
  264. }
  265. sharpSrc = sharp(src)
  266. sharpSrc.extend({
  267. top: 726,
  268. bottom: 726,
  269. left: 726,
  270. right: 726,
  271. background: {
  272. r: rgb.r,
  273. g: rgb.g,
  274. b: rgb.b,
  275. alpha: 1
  276. }
  277. })
  278. .flatten({ background: { r: rgb.r, g: rgb.g, b: rgb.b, alpha: 1 } })
  279. } else if (options.splashscreen_type === 'overlay') {
  280. sharpSrc = sharp(splashSrc)
  281. .flatten({ background: { r: rgb.r, g: rgb.g, b: rgb.b, alpha: 1 } })
  282. .composite([{
  283. input: src
  284. // blend: 'multiply' <= future work, maybe just a gag
  285. }])
  286. } else if (options.splashscreen_type === 'pure') {
  287. sharpSrc = sharp(splashSrc)
  288. .flatten({ background: { r: rgb.r, g: rgb.g, b: rgb.b, alpha: 1 } })
  289. }
  290. const data = await sharpSrc.toBuffer()
  291. for (const optionKey in options) {
  292. const option = options[optionKey]
  293. for (const sizeKey in option.sizes) {
  294. const size = option.sizes[sizeKey]
  295. if (option.splash) {
  296. const dest = `${target}${path.sep}${option.folder}`
  297. await ensureDir(dest)
  298. if (option.infix === true) {
  299. output = `${dest}${path.sep}${option.prefix}${size}x${size}${option.suffix}`
  300. } else {
  301. output = `${dest}${path.sep}${option.prefix}${option.suffix}`
  302. }
  303. const pvar = [output, size]
  304. let sharpData = sharp(data)
  305. sharpData = sharpData.resize(pvar[1][0], pvar[1][1])
  306. await sharpData.toFile(pvar[0])
  307. }
  308. }
  309. }
  310. },
  311. /**
  312. * Minifies a set of images
  313. *
  314. * @param {string} target - image location
  315. * @param {object} options - where to drop the images
  316. * @param {string} strategy - which minify strategy to use
  317. * @param {string} mode - singlefile or batch
  318. */
  319. minify: async function (target, options, strategy, mode) {
  320. let cmd
  321. const minify = settings.options.minify
  322. if (!minify.available.find(x => x === strategy)) {
  323. strategy = minify.type
  324. }
  325. switch (strategy) {
  326. case 'pngquant':
  327. cmd = pngquant(minify.pngquantOptions)
  328. break
  329. case 'optipng':
  330. cmd = optipng(minify.optipngOptions)
  331. break
  332. case 'zopfli':
  333. cmd = zopfli(minify.zopfliOptions)
  334. break
  335. }
  336. const __minifier = async (pvar) => {
  337. await imagemin([pvar[0]], {
  338. destination: pvar[1],
  339. plugins: [cmd]
  340. }).catch(err => {
  341. warn(err)
  342. })
  343. }
  344. switch (mode) {
  345. case 'singlefile':
  346. await __minifier([target, path.dirname(target)], cmd)
  347. break
  348. case 'batch':
  349. // eslint-disable-next-line no-case-declarations
  350. const folders = uniqueFolders(options)
  351. for (const n in folders) {
  352. log('batch minify:', folders[n])
  353. await __minifier([
  354. `${target}${path.sep}${folders[n]}${path.sep}*.png`,
  355. `${target}${path.sep}${folders[n]}`
  356. ], cmd)
  357. }
  358. break
  359. default:
  360. warn('[ERROR] Minify mode must be one of [ singlefile | batch]')
  361. process.exit(1)
  362. }
  363. return 'minified'
  364. },
  365. /**
  366. * Creates special icns and ico filetypes
  367. *
  368. * @param {string} src - image location
  369. * @param {string} target - where to drop the images
  370. * @param {object} options
  371. * @param {string} strategy
  372. */
  373. icns: async function (src, target, options, strategy) {
  374. try {
  375. if (!image) {
  376. process.exit(1)
  377. }
  378. await this.validate(src, target)
  379. const sharpSrc = sharp(src)
  380. const buf = await sharpSrc.toBuffer()
  381. const out = await png2icons.createICNS(buf, png2icons.BICUBIC, 0)
  382. ensureFileSync(path.join(target, '/icon.icns'))
  383. writeFileSync(path.join(target, '/icon.icns'), out)
  384. const out2 = await png2icons.createICO(buf, png2icons.BICUBIC, 0, true)
  385. ensureFileSync(path.join(target, '/icon.ico'))
  386. writeFileSync(path.join(target, '/icon.ico'), out2)
  387. } catch (err) {
  388. console.error(err)
  389. }
  390. }
  391. }
  392. if (typeof exports !== 'undefined') {
  393. if (typeof module !== 'undefined' && module.exports) {
  394. exports = module.exports = tauricon
  395. }
  396. exports.tauricon = tauricon
  397. }