module IOSIconGenerator::Helpers
The helpers used by the commands of IOSIconGenerator
.
Public Class Methods
# File lib/ios_icon_generator/helpers/check_dependencies.rb, line 21 def self.check_dependencies(requires_ghostscript: false) raise "#{'ImageMagick'.blue.bold} is required. It can be installed via #{'homebrew'.bold.underlined} using #{'brew install imagemagick'.blue.bold.underlined}" unless Helpers.which('magick') raise "#{'GhostScript'.blue.bold} is required. It can be installed via #{'homebrew'.bold.underlined} using #{'brew install ghostscript'.blue.bold.underlined}" \ if requires_ghostscript && !Helpers.which('gs') end
Generate an icon using the base icon provided.
If icon_path
is set to nil
, the function expects generate_icon
to be set or the function will raise.
@param [String, read] icon_path The path to the icon to use as the base icon.
If specified, it must point to a valid image file, with a resolution over 1024x1024 when applicable. If not specified, +generate_icon+ must be specified.
@param [String, read] output_folder The folder to create the app icon set in. @param [Array<Symbol>, read] types The types to generate the sets of images for. Each type must be one of :iphone
, :ipad
, :watch
, mac
or carplay
, or it can be an array of just :imessage
. @param [Symbol, read] parallel_processes The number of processes to use when generating the icons.
+nil+ means it'll use as many processes as they are cores on the machine. +0+ will disables spawning any processes.
@param [Lambda(base_path [String], target_path [String], width [Float], height [Float]), read] generate_icon
The lambda that actually generates the icon.
If none is specified, and default one will be used. It should take four parameters: - +base_path+: The base path to the reference image to use to generate the new icon. If +icon_path+ is set to +nil+, the +base_path+ parameter will +nil+ as well. - +target_path+: The path to generate the icon at. - +width+: The width of the icon to generate. - +height+: The height of the icon to generate.
@param [Lambda(progress [Int], total [Int]), read] progress An optional progress block called when progress has been made generating the icons.
It should take two parameters: - +progress+: An integer indicating the current progress out of +total+ - +total+: An integer indicating the total progress
@return [String] Return the path to the generated app icon set.
# File lib/ios_icon_generator/helpers/generate_icon.rb, line 50 def self.generate_icon(icon_path:, output_folder:, types:, parallel_processes: nil, generate_icon: nil, progress: nil) is_pdf = icon_path && File.extname(icon_path) == '.pdf' Helpers.check_dependencies(requires_ghostscript: is_pdf) if icon_path raise "There is no icon at #{icon_path}." unless File.exist?(icon_path) matches = /(\d+)x(\d+)/.match(`magick identify "#{icon_path}"`) raise 'Unable to verify icon. Please make sure it\'s a valid image file and try again.' if matches.nil? width, height = matches.captures raise 'Invalid image specified.' if width.nil? || height.nil? raise "The icon must at least be 1024x1024, it currently is #{width}x#{height}." unless width.to_i >= 1024 && height.to_i >= 1024 elsif generate_icon.nil? raise 'icon_path has been set to nil, generate_icon must be specified' end appiconset_path = File.join(output_folder, "#{types.include?(:imessage) ? 'iMessage App Icon' : 'AppIcon'}.#{types.include?(:imessage) ? 'stickersiconset' : 'appiconset'}") FileUtils.mkdir_p(appiconset_path) get_icon_path = lambda { |width, height| return File.join(appiconset_path, "Icon-#{width.to_i}x#{height.to_i}.png") } generate_icon ||= lambda { |base_path, target_path, width, height| size = [width, height].max system( 'magick', 'convert', '-density', '400', base_path, '-colorspace', 'sRGB', '-type', 'truecolor', '-resize', "#{size}x#{size}", '-gravity', 'center', '-crop', "#{width}x#{height}+0+0", '+repage', target_path ) } types.each do |type1| types.each do |type2| raise "Incompatible types used together: #{type1} and #{type2}. These types cannot be added to the same sets; please call the command twice with each different type." if Helpers.type_incompatible?(type1, type2) end end images_sets = Helpers.image_sets(types) smaller_sizes = [] images_sets.each do |image| width, height = /(\d+(?:\.\d)?)x(\d+(?:\.\d)?)/.match(image['size'])&.captures scale, = /(\d+(?:\.\d)?)x/.match(image['scale'])&.captures raise "Invalid size parameter in Contents.json: #{image['size']}" if width.nil? || height.nil? || scale.nil? scale = scale.to_f width = width.to_f * scale height = height.to_f * scale target_path = get_icon_path.call(width, height) image['filename'] = File.basename(target_path) if width > 512 || height > 512 generate_icon.call( icon_path, target_path, width, height ) else smaller_sizes << [width, height] end end total = smaller_sizes.count + 2 progress&.call(nil, total) max_size = smaller_sizes.flatten.max temp_icon_path = File.join(output_folder, ".temp_icon#{is_pdf ? '.pdf' : '.png'}") begin system('magick', 'convert', '-density', '400', icon_path, '-colorspace', 'sRGB', '-type', 'truecolor', '-scale', "#{max_size}x#{max_size}", temp_icon_path) if icon_path progress&.call(1, total) Parallel.each( smaller_sizes, in_processes: parallel_processes, finish: lambda do |_item, i, _result| progress&.call(i + 1, total) end ) do |width, height| generate_icon.call( temp_icon_path, get_icon_path.call(width, height), width, height ) end ensure FileUtils.rm(temp_icon_path) if File.exist?(temp_icon_path) end contents_json = { images: images_sets, info: { version: 1, author: 'xcode', }, } File.write(File.join(appiconset_path, 'Contents.json'), JSON.pretty_generate(contents_json)) progress&.call(total - 1, total) appiconset_path end
Get the image sets for the given types.
@param [Symbol, read] types The types to return the sets of image for.
This method won't fail if the types aren't compatible as defined by +type_incompatible?+
@return [Array<Hash<String, String>>] The sets of image for the given types.
Each hash will at least contain a +size+ [String] key, that has the format +<width>x<height>+
# File lib/ios_icon_generator/helpers/image_sets_definition.rb, line 29 def self.image_sets(types) types.flat_map do |type| contents_path = File.expand_path(File.join(File.dirname(__FILE__), "../../../vendor/Contents-#{type}.json")) raise "Unknown type #{type}" unless File.exist?(contents_path) contents_json = JSON.parse(File.read(contents_path)) contents_json['images'] end end
Mask an icon using the parameters provided.
The mask is for now always generated in the bottom left corner of the image.
@param [String, read] appiconset_path The path of the original app icon set to use to generate the new one. @param [String, read] output_folder The folder to create the new app icon set in. @param [Hash<String, Object>, read] mask A hash representing parameters for creating the mask.
The Hash may contain the following values: - +background_color+: The background color to use when generating the mask - +stroke_color+: The stroke color to use when generating the mask. Used for the outline of the mask. - +stroke_width_offset+: The stroke width of the mask, offset to the image's minimum dimension (width or height). 1.0 means the stroke will have the full width/height of the image - +suffix+: The suffix to use when generating the new mask - +file+: The file to use when generating the new mask. This file should be an image, and it will be overlayed over the background. - +symbol+: The symbol to use when generating the new mask - +symbol_color+: The color to use for the symbol - +font+: The font to use for the symbol - +x_size_ratio+: The size ratio (of the width of the image) to use when generating the mask. 1.0 means the full width, 0.5 means half-width. - +y_size_ratio+: The size ratio (of the height of the image) to use when generating the mask. 1.0 means the full height, 0.5 means half-height. - +size_offset+: The size ratio (of the width and height) to use when generating the symbol or file. 1.0 means the full width and height, 0.5 means half-width and half-height. - +x_offset+: The X offset (of the width of the image) to use when generating the symbol or file. 1.0 means the full width, 0.5 means half-width. - +y_offset+: The Y offset (of the width of the image) to use when generating the symbol or file. 1.0 means the full height, 0.5 means half-height. - +shape+: The shape to use when generating the mask. Can be either +:triangle+ or +:square+.
@param [Symbol, read] parallel_processes The number of processes to use when generating the icons.
+nil+ means it'll use as many processes as they are cores on the machine. +0+ will disables spawning any processes.
@param [Lambda(progress [Int], total [Int]), read] progress An optional progress block called when progress has been made generating the icons.
It should take two parameters: - +progress+: An integer indicating the current progress out of +total+ - +total+: An integer indicating the total progress
@return [String] Return the path to the generated app icon set.
# File lib/ios_icon_generator/helpers/mask_icon.rb, line 57 def self.mask_icon( appiconset_path:, output_folder:, mask: { background_color: '#FFFFFF', stroke_color: '#000000', stroke_width_offset: 0.1, suffix: 'Beta', symbol: 'b', symbol_color: '#7F0000', font: 'Helvetica', x_size_ratio: 0.54, y_size_ratio: 0.54, size_offset: 0.0, x_offset: 0.0, y_offset: 0.0, shape: 'triangle', }, parallel_processes: nil, progress: nil ) Helpers.check_dependencies extension = File.extname(appiconset_path) output_folder = File.join(output_folder, "#{File.basename(appiconset_path, extension)}-#{mask[:suffix]}#{extension}") FileUtils.mkdir_p(output_folder) contents_path = File.join(appiconset_path, 'Contents.json') raise "Contents.json file not found in #{appiconset_path}" unless File.exist?(contents_path) json_content = JSON.parse(File.read(contents_path)) progress&.call(nil, json_content['images'].count) Parallel.each( json_content['images'], in_processes: parallel_processes, finish: lambda do |_item, i, result| json_content['images'][i]['filename'] = result progress&.call(i, json_content['images'].count) end ) do |image| width, height = /(\d+(?:\.\d)?)x(\d+(?:\.\d)?)/.match(image['size'])&.captures scale, = /(\d+(?:\.\d)?)x/.match(image['scale'])&.captures raise "Invalid size parameter in Contents.json: #{image['size']}" if width.nil? || height.nil? || scale.nil? scale = scale.to_f width = width.to_f * scale height = height.to_f * scale mask_size_width = width * mask[:x_size_ratio].to_f mask_size_height = height * mask[:y_size_ratio].to_f extension = File.extname(image['filename']) icon_output = "#{File.basename(image['filename'], extension)}-#{mask[:suffix]}#{extension}" icon_output_path = File.join(output_folder, icon_output) draw_shape_parameters = "-strokewidth '#{(mask[:stroke_width_offset] || 0) * [width, height].min}' \ -stroke '#{mask[:stroke_width_offset].zero? ? 'none' : (mask[:stroke_color] || '#000000')}' \ -fill '#{mask[:background_color] || '#FFFFFF'}'" draw_shape = case mask[:shape] when :triangle "-draw \"polyline -#{width},#{height - mask_size_height} 0,#{height - mask_size_height} #{mask_size_width},#{height} #{mask_size_width},#{height * 2.0} -#{width},#{height * 2.0}\"" when :square "-draw \"rectangle -#{width},#{height * 2.0} #{mask_size_height},#{width - mask_size_width}\"" else raise "Unknown mask shape: #{mask[:shape]}" end draw_symbol = if mask[:file] "\\( -background none \ -density 1536 \ -resize #{width * mask[:size_offset]}x#{height} \ \"#{mask[:file]}\" \ -geometry +#{width * mask[:x_offset]}+#{height * mask[:y_offset]} \\) \ -gravity southwest \ -composite" else "-strokewidth 0 \ -stroke none \ -fill '#{mask[:symbol_color] || '#7F0000'}' \ -font '#{mask[:font]}' \ -pointsize #{height * mask[:size_offset] * 2.0} \ -annotate +#{width * mask[:x_offset]}+#{height - height * mask[:y_offset]} '#{mask[:symbol]}'" end system("convert '#{File.join(appiconset_path, image['filename'])}' #{draw_shape_parameters} #{draw_shape} #{draw_symbol} '#{icon_output_path}'") next icon_output end File.write(File.join(output_folder, 'Contents.json'), JSON.pretty_generate(json_content)) output_folder end
Check if the given types are compatible (if they can be used in the same set)
@param [Symbol, read] lhs The first type to check against the second type. @param [Symbol, read] rhs The second type to check against the first type.
@return [Boolean] true
if the given are compatible together, false
otherwise
# File lib/ios_icon_generator/helpers/image_sets_definition.rb, line 46 def self.type_incompatible?(lhs, rhs) (lhs == :imessage && rhs != :imessage) || (lhs != :imessage && rhs == :imessage) end
Cross-platform way of finding an executable in the +$PATH+.
From stackoverflow.com/questions/2108727/which-in-ruby-checking-if-program-exists-in-path-from-ruby
@param cmd [String] The name of the command to search the path for.
@return [String] The full path to the command if found, and nil
otherwise.
# File lib/ios_icon_generator/helpers/which.rb, line 27 def self.which(cmd) exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : [''] ENV['PATH'].split(File::PATH_SEPARATOR).each do |path| exts.each do |ext| exe = File.join(path, "#{cmd}#{ext}") return exe if File.executable?(exe) && !File.directory?(exe) end end nil end