module IOSIconGenerator::Helpers

The helpers used by the commands of IOSIconGenerator.

Public Class Methods

check_dependencies(requires_ghostscript: false) click to toggle source
# 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_icon(icon_path:, output_folder:, types:, parallel_processes: nil, generate_icon: nil, progress: nil) click to toggle source

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
image_sets(types) click to toggle source

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_icon( appiconset_path:, output_folder:, mask: { background_color: ' click to toggle source

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
type_incompatible?(lhs, rhs) click to toggle source

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
which(cmd) click to toggle source

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