PageRenderTime 60ms CodeModel.GetById 24ms RepoModel.GetById 0ms app.codeStats 0ms

/src/modules/ios.coffee

https://gitlab.com/arreeba-dev/assetpress
CoffeeScript | 333 lines | 224 code | 55 blank | 54 comment | 60 complexity | bab7d500180ce87c56e1d479f7a0a201 MD5 | raw file
  1. fs = require 'fs-extra'
  2. path = require 'path'
  3. _ = require 'lodash'
  4. im = require('gm').subClass imageMagick: true
  5. async = require 'async'
  6. walk = require 'walkdir'
  7. tmp = require 'temporary'
  8. iOSXCAssets = require './ios-xcassets'
  9. iOSConstants = require './ios-constants'
  10. util = require '../utilities'
  11. inputDirectory = ''
  12. outputDirectory = ''
  13. temporaryDirectory = ''
  14. options = {}
  15. defaults =
  16. minimum: 1
  17. maximum: 3
  18. minimumPhone: 2
  19. maximumPhone: 3
  20. minimumPad: 1
  21. maximumPad: 2
  22. xcassets: false
  23. processImage = (task, callback) ->
  24. info = task.info # Entire descriptor
  25. scaleKey = '' + task.scale # Lookup key
  26. highestScale = info.devices[task.device].highestScale
  27. # iOS scaling naming: nothing, @2x, @3x...
  28. scaleSuffix = if task.scale is 1 then '' else "@#{ task.scale }x"
  29. # Universal resources are simply icon@2x.png, device specific are icon@2x~iphone.png
  30. # AssetPress accepts both icon@2x~iphone.png (correct) and icon~iphone@2x.png (incorrect)
  31. deviceSuffix = if task.device is 'universal' then '' else '~' + task.device
  32. if task.scale > highestScale
  33. # If we were expecting 3x image, but only 2x was provided, don't upscale.
  34. if ( info.id.indexOf('AppIcon') isnt 0 and info.id.indexOf('Default') isnt 0 )
  35. # Mr. Complainy Pants. Doesn't complain about AppIcons and Launch Images, but does complain about missing resolutions.
  36. process.stdout.write "WARNING: Missing image #{ info.id + scaleSuffix + deviceSuffix + info.extension }\n"
  37. return callback()
  38. fs.ensureDirSync temporaryDirectory + info.foldername
  39. # Setting up image
  40. # If file exists, it will copied, so no need to load highest scale version
  41. if _.has info.devices[task.device], scaleKey
  42. image = im(info.devices[task.device][scaleKey])
  43. # Otherwise, it will be scaled down, so we load highest scale version
  44. else
  45. image = im(info.devices[task.device]['' + highestScale])
  46. # Making sure ImageMagic doesn't add date chunk that creates a binary difference where there are none.
  47. .out '-define', 'png:exclude-chunk=tIME,tEXt,zTXt,date'
  48. image.size (err, size) ->
  49. process.stdout.write err + '\n' if err
  50. # 1. Make sure that this resource is correct and needed.
  51. outputPath = info.id + scaleSuffix + deviceSuffix + info.extension
  52. # Additional check for AppIcons: making sure such icon really exists.
  53. if info.id.indexOf('AppIcon') is 0
  54. unchangedOutputPath = outputPath
  55. # Checking unmodified name and iPhone-specific version
  56. iPhoneOutputPath = info.id + scaleSuffix + '~iphone' + info.extension
  57. # For app icons we are also using bareFormat function, so both AppIcon and AppIconTestfligh works.
  58. if (
  59. _.contains(iOSConstants.appIconList, iOSConstants.bareFormat(outputPath, 'AppIcon')) or
  60. _.contains(iOSConstants.appIconList, iOSConstants.bareFormat(iPhoneOutputPath, 'AppIcon'))
  61. )
  62. # If we only found iPhone-specific image, modify variables accordingly
  63. if (
  64. !_.contains(iOSConstants.appIconList, iOSConstants.bareFormat(outputPath, 'AppIcon')) and
  65. _.contains(iOSConstants.appIconList, iOSConstants.bareFormat(iPhoneOutputPath, 'AppIcon'))
  66. )
  67. task.device = 'iphone'
  68. deviceSuffix = '~iphone'
  69. outputPath = iPhoneOutputPath
  70. # Found App Icon, verify size.
  71. expectedSize = iOSConstants.getAppIconInfo(outputPath).size
  72. if expectedSize isnt size.width or expectedSize isnt size.height
  73. process.stdout.write "WARNING: App Icon #{ unchangedOutputPath } should be #{ expectedSize }x#{ expectedSize }, but it is #{ size.width }x#{ size.height }.\n"
  74. else
  75. process.stdout.write "WARNING: Unknown App Icon #{ outputPath }\n"
  76. return callback()
  77. # Same checks for Launch Images
  78. if info.id.indexOf('Default') is 0
  79. unchangedOutputPath = outputPath
  80. iPhoneOutputPath = info.id + scaleSuffix + '~iphone' + info.extension
  81. if (
  82. _.contains(iOSConstants.launchImageList, outputPath) or
  83. _.contains(iOSConstants.launchImageList, iPhoneOutputPath)
  84. )
  85. if (
  86. !_.contains(iOSConstants.launchImageList, outputPath) and
  87. _.contains(iOSConstants.launchImageList, iPhoneOutputPath)
  88. )
  89. task.device = 'iphone'
  90. deviceSuffix = '~iphone'
  91. outputPath = iPhoneOutputPath
  92. # Found Launch Image, verify size.
  93. launchImageInfo = iOSConstants.getLaunchImageInfo outputPath
  94. expectedWidth = launchImageInfo.width
  95. expectedHeight = launchImageInfo.height
  96. if expectedWidth isnt size.width or expectedHeight isnt size.height
  97. process.stdout.write "WARNING: Launch Image #{ unchangedOutputPath } should be #{ expectedWidth }x#{ expectedHeight }, but it is #{ size.width }x#{ size.height }.\n"
  98. else
  99. process.stdout.write "WARNING: Unknown Launch Image #{ outputPath }\n"
  100. return callback()
  101. # 2. Update output path for Xcassets
  102. if options.xcassets
  103. if info.id.indexOf('AppIcon') is 0
  104. appIconRoot = info.id.split(/-|~|@/)[0]
  105. appIconRootSuffix = appIconRoot.substr(7) # AppIcon | Testflight
  106. outputPath = "AppIcon#{ appIconRootSuffix }.appiconset/" + info.id + scaleSuffix + deviceSuffix + info.extension
  107. fs.ensureDirSync path.resolve(temporaryDirectory, "AppIcon#{ appIconRootSuffix }.appiconset/")
  108. else if info.id.indexOf('Default') is 0
  109. outputPath = 'LaunchImage.launchimage/' + info.id + scaleSuffix + deviceSuffix + info.extension
  110. fs.ensureDirSync path.resolve(temporaryDirectory, 'LaunchImage.launchimage/')
  111. else
  112. outputPath = info.id + '.imageset/' + info.basename + scaleSuffix + deviceSuffix + info.extension
  113. fs.ensureDirSync path.resolve(temporaryDirectory, info.id + '.imageset/')
  114. # 3. Final action is copy or resize
  115. destinationPath = path.resolve temporaryDirectory, outputPath
  116. # If the file already exists, we need to copy it.
  117. if _.has info.devices[task.device], scaleKey
  118. if info.id.indexOf('AppIcon') is 0
  119. # For App Icons we always remove alpha channel.
  120. # It is a requirement by Apple.
  121. image
  122. .out '-background', 'white'
  123. .out '-alpha', 'remove'
  124. .out '-define', 'png:exclude-chunk=tIME,tEXt,zTXt,date'
  125. .write destinationPath, (err) ->
  126. process.stdout.write err + '\n' if err
  127. process.stdout.write "Copied prerendered App Icon #{ info.id + scaleSuffix + deviceSuffix + info.extension } and removed alpha channel.\n" if options.verbose
  128. return callback()
  129. else
  130. fs.copy info.devices[task.device][scaleKey], destinationPath, ->
  131. process.stdout.write "Copied prerendered image #{ info.id + scaleSuffix + deviceSuffix + info.extension }\n" if options.verbose
  132. return callback()
  133. # Otherwise, scale down the highest scale image
  134. else
  135. scaleRatio = task.scale / highestScale
  136. image
  137. .filter iOSConstants.resizeFilter
  138. .resize Math.round(size.width * scaleRatio), Math.round(size.height * scaleRatio), '!'
  139. .out '-define', 'png:exclude-chunk=tIME,tEXt,zTXt,date'
  140. .write destinationPath, (err) ->
  141. process.stdout.write err + '\n' if err
  142. process.stdout.write "Scaled image #{ info.id + scaleSuffix + deviceSuffix + info.extension }\n" if options.verbose
  143. return callback()
  144. # Takes a directory and returns an object with files grouped, organized by device and scale.
  145. describeInputDirectory = (inputDirectory) ->
  146. # Constructs list of files in input directory, with input directory path removed.
  147. paths = _.map walk.sync(inputDirectory), (filepath) -> filepath.replace inputDirectory, ''
  148. # Extention whitelist
  149. allowedExtensions = if options.xcassets then iOSConstants.xcassetsAllowedExtensions else iOSConstants.directoryAllowedExtensions
  150. filtered = _.filter paths, (filepath) ->
  151. # Drop if not a file (we can't resize a directory)
  152. return false if !fs.lstatSync( path.resolve(inputDirectory, filepath) ).isFile()
  153. # Drop if it's a hidden file (filename starts with .)
  154. return false if path.basename(filepath).slice(0, 1) == '.'
  155. # Drop if file extension is not in the whitelist
  156. extension = path.extname filepath
  157. if !_.contains allowedExtensions, extension
  158. process.stdout.write "File #{ filepath } in unsupported format for current output.\n"
  159. return false
  160. # Drop everything that has _ anywhere in path
  161. pathSegments = util.removeTrailingSlash(filepath).split '/'
  162. return false for segment in pathSegments when segment.slice(0, 1) is '_'
  163. # Everything else shall pass
  164. true
  165. # Bundles related files together
  166. grouped = _.groupBy filtered, (filepath) ->
  167. filepath
  168. .slice 0, path.extname(filepath).length * -1 # We don't care about extention
  169. .replace(/@(\d+)x/, '') # Scale
  170. .replace(/~([a-z]+)/, '') # Or device
  171. # Conveniently, this returns a common name for resource, that is used as a key, for example icons/menuIcon
  172. # Image descriptors is a list of objects that describe these bundled groups in detail.
  173. imageDescriptors = []
  174. for identifier of grouped
  175. groupPaths = grouped[identifier]
  176. descriptor =
  177. id: identifier
  178. basename: path.basename identifier
  179. extension: path.extname groupPaths[0]
  180. devices: {}
  181. descriptor.foldername = path.dirname identifier
  182. if descriptor.foldername is '.' then descriptor.foldername = '' else descriptor.foldername += '/'
  183. # Normalize JPEG extension for later.
  184. descriptor.extension = '.jpg' if descriptor.extension == '.jpeg'
  185. for filepath in groupPaths
  186. # Scale
  187. scaleMatch = filepath.match /@(\d+)x/
  188. scale = if scaleMatch then parseInt scaleMatch[1] else 1
  189. # Device
  190. deviceMatch = filepath.match /~([a-z]+)/i
  191. device = if deviceMatch then deviceMatch[1].toLowerCase() else 'universal'
  192. # Add this, so its descriptor.devices.universal.2
  193. descriptor.devices[device] = {} if !_.has descriptor.devices, device
  194. descriptor.devices[device][scale] = path.resolve inputDirectory, filepath
  195. # Images will be scaled down from the highest scale image (4x recommended)
  196. for device, groupPaths of descriptor.devices
  197. highestScale = _.max _.keys(groupPaths), (key) -> parseInt key
  198. descriptor.devices[device].highestScale = parseInt highestScale
  199. # In Xcassets folder both device-specific and universal resources are not allowed,
  200. # If descriptor.devices has both, univeral is removed.
  201. if (
  202. options.xcassets and
  203. ( _.has(descriptor.devices, 'iphone') or _.has(descriptor.devices, 'ipad') ) and
  204. _.has(descriptor.devices, 'universal') and
  205. !( identifier.indexOf('AppIcon') == 0 or identifier.indexOf('Default') == 0 )
  206. )
  207. delete descriptor.devices.universal
  208. imageDescriptors.push descriptor
  209. imageDescriptors
  210. module.exports = (passedInputDirectory, passedOutputDirectory = false, passedOptions = {}, callback = false) ->
  211. inputDirectory = util.addTrailingSlash util.resolvePath(passedInputDirectory)
  212. outputDirectory = passedOutputDirectory
  213. options = _.defaults passedOptions, defaults
  214. unless callback then callback = -> # noop
  215. # Numberify options
  216. options.minimum = parseInt options.minimum
  217. options.maximum = parseInt options.maximum
  218. options.minimumPhone = parseInt options.minimumPhone
  219. options.maximumPhone = parseInt options.maximumPhone
  220. options.minimumPad = parseInt options.minimumPad
  221. options.maximumPad = parseInt options.maximumPad
  222. outputDirectoryName = if passedOutputDirectory then util.removeTrailingSlash(passedOutputDirectory) else 'Images'
  223. outputDirectoryName += '.xcassets' if options.xcassets and !_.endsWith(outputDirectoryName, '.xcassets')
  224. outputDirectoryBase = util.resolvePath inputDirectory, '..'
  225. outputDirectory = util.addTrailingSlash util.resolvePath(outputDirectoryBase, outputDirectoryName)
  226. temporaryDirectoryObject = new tmp.Dir
  227. temporaryDirectory = util.addTrailingSlash temporaryDirectoryObject.path
  228. queue = async.queue processImage, 1
  229. queue.drain = ->
  230. # These are the final actions: moving results from temporary folder to final output
  231. util.move temporaryDirectory, outputDirectory, options.clean
  232. # And removing temporary folder.
  233. fs.removeSync temporaryDirectory
  234. # We either end here or continue with XCAssets JSONs
  235. if options.xcassets
  236. iOSXCAssets outputDirectory, { verbose: options.verbose }, callback
  237. else
  238. callback()
  239. # Image descriptors is the master data, that describes all image groups
  240. imageDescriptors = describeInputDirectory inputDirectory
  241. # Very useful debug line
  242. # console.log require('util').inspect(imageDescriptors, false, null)
  243. for device in iOSConstants.deviceTypes
  244. # min and max densities make sure that all needed scales are created.
  245. # min and max absolute densities make sure that impossible scales (4x+) are not created.
  246. [ minDensity, maxDensity, absoluteMinDensity, absoluteMaxDensity ] = iOSConstants.getDensityLimits(device, options)
  247. for descriptor in imageDescriptors
  248. if _.has descriptor.devices, device
  249. # Here we deal with certain exceptions
  250. adjustedMinDensity = minDensity
  251. adjustedMaxDensity = maxDensity
  252. if _.has iOSConstants.scalerExceptions, descriptor.id
  253. adjustments = iOSConstants.scalerExceptions[descriptor.id]
  254. adjustedMinDensity = adjustments.minDensity if adjustments.minDensity
  255. adjustedMaxDensity = adjustments.maxDensity if adjustments.maxDensity
  256. # Set up all expected results
  257. scale = adjustedMinDensity
  258. while scale <= adjustedMaxDensity
  259. queue.push
  260. info: descriptor
  261. device: device
  262. scale: scale
  263. scale++
  264. # Don't skip any pre-rendered images, unless they are beyond maximum limits.
  265. for scale of descriptor.devices[device]
  266. # TODO all these 3 lines - what do they do?
  267. if scale is 'highestScale'
  268. scale++
  269. continue
  270. scale = parseInt scale
  271. if (
  272. (scale < adjustedMinDensity or scale > adjustedMaxDensity) and # Otherwise it is already generated
  273. scale >= absoluteMinDensity and scale <= absoluteMaxDensity # Not beyond maximum limits
  274. )
  275. queue.push
  276. info: descriptor
  277. device: device
  278. scale: scale