Module: IOSIconGenerator::Helpers

Defined in:
lib/ios_icon_generator.rb,
lib/ios_icon_generator/helpers/which.rb,
lib/ios_icon_generator/helpers/mask_icon.rb,
lib/ios_icon_generator/helpers/generate_icon.rb,
lib/ios_icon_generator/helpers/check_dependencies.rb,
lib/ios_icon_generator/helpers/image_sets_definition.rb

Overview

The helpers used by the commands of IOSIconGenerator.

Class Method Summary collapse

Class Method Details

.check_dependencies(requires_ghostscript: false) ⇒ Object



21
22
23
24
25
# 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) ⇒ String

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.

Parameters:

  • icon_path (String, #read)

    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.

  • output_folder (String, #read)

    The folder to create the app icon set in.

  • types (Array<Symbol>, #read)

    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.

  • parallel_processes (Symbol, #read) (defaults to: nil)

    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.

  • generate_icon (Lambda(base_path [String], target_path [String], width [Float], height [Float]), #read) (defaults to: nil)

    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.

  • progress (Lambda(progress [Int], total [Int]), #read) (defaults to: nil)

    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

Returns:

  • (String)

    Return the path to the generated app icon set.



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# 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) ⇒ Array<Hash<String, String>>

Get the image sets for the given types.

Parameters:

  • types (Symbol, #read)

    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?

Returns:

  • (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>



29
30
31
32
33
34
35
36
37
# 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: '#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) ⇒ String

Mask an icon using the parameters provided.

The mask is for now always generated in the bottom left corner of the image.

Parameters:

  • appiconset_path (String, #read)

    The path of the original app icon set to use to generate the new one.

  • output_folder (String, #read)

    The folder to create the new app icon set in.

  • mask (Hash<String, Object>, #read) (defaults to: { 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', })

    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.

  • parallel_processes (Symbol, #read) (defaults to: nil)

    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.

  • progress (Lambda(progress [Int], total [Int]), #read) (defaults to: nil)

    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

Returns:

  • (String)

    Return the path to the generated app icon set.



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# 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) ⇒ Boolean

Check if the given types are compatible (if they can be used in the same set)

Parameters:

  • lhs (Symbol, #read)

    The first type to check against the second type.

  • rhs (Symbol, #read)

    The second type to check against the first type.

Returns:

  • (Boolean)

    true if the given are compatible together, false otherwise



46
47
48
# 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) ⇒ String

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

Parameters:

  • cmd (String)

    The name of the command to search the path for.

Returns:

  • (String)

    The full path to the command if found, and nil otherwise.



27
28
29
30
31
32
33
34
35
36
# 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