tauricon.js 12 KB

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