sprites.thor 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. class SpritesCLI < Thor
  2. def self.to_s
  3. 'Sprites'
  4. end
  5. def initialize(*args)
  6. require 'docs'
  7. require 'chunky_png'
  8. require 'fileutils'
  9. super
  10. end
  11. desc 'generate [--verbose]', 'Generate the documentation icon spritesheets'
  12. option :verbose, type: :boolean
  13. def generate
  14. items = get_items
  15. items_with_icons = items.select {|item| item[:has_icons]}
  16. items_without_icons = items.select {|item| !item[:has_icons]}
  17. icons_per_row = Math.sqrt(items_with_icons.length).ceil
  18. bg_color = get_sidebar_background
  19. items_with_icons.each_with_index do |item, index|
  20. item[:row] = (index / icons_per_row).floor
  21. item[:col] = index - item[:row] * icons_per_row
  22. item[:icon_16] = get_icon(item[:path_16], 16)
  23. item[:icon_32] = get_icon(item[:path_32], 32)
  24. item[:dark_icon_fix] = needs_dark_icon_fix(item[:icon_32], bg_color)
  25. end
  26. log_details(items_with_icons, icons_per_row)
  27. generate_spritesheet(16, items_with_icons, 'assets/images/sprites/docs.png') {|item| item[:icon_16]}
  28. generate_spritesheet(32, items_with_icons, 'assets/images/sprites/docs@2x.png') {|item| item[:icon_32]}
  29. # Add Mongoose's icon details to docs without custom icons
  30. default_item = items_with_icons.find {|item| item[:type] == 'mongoose'}
  31. items_without_icons.each do |item|
  32. item[:row] = default_item[:row]
  33. item[:col] = default_item[:col]
  34. item[:dark_icon_fix] = default_item[:dark_icon_fix]
  35. end
  36. save_manifest(items, icons_per_row, 'assets/images/sprites/docs.json')
  37. end
  38. private
  39. def get_items
  40. items = Docs.all.map do |doc|
  41. base_path = "public/icons/docs/#{doc.slug}"
  42. {
  43. :type => doc.slug,
  44. :path_16 => "#{base_path}/16.png",
  45. :path_32 => "#{base_path}/16@2x.png"
  46. }
  47. end
  48. # Checking paths against an array of possible paths is faster than 200+ File.exist? calls
  49. files = Dir.glob('public/icons/docs/**/*.png')
  50. items.each do |item|
  51. item[:has_icons] = files.include?(item[:path_16]) && files.include?(item[:path_32])
  52. end
  53. end
  54. def get_icon(path, max_size)
  55. icon = ChunkyPNG::Image.from_file(path)
  56. # Check if the icon is too big
  57. # If it is, resize the image without changing the aspect ratio
  58. if icon.width > max_size || icon.height > max_size
  59. ratio = icon.width.to_f / icon.height
  60. new_width = (icon.width >= icon.height ? max_size : max_size * ratio).floor
  61. new_height = (icon.width >= icon.height ? max_size / ratio : max_size).floor
  62. logger.warn("Icon #{path} is too big: max size is #{max_size} x #{max_size}, icon is #{icon.width} x #{icon.height}, resizing to #{new_width} x #{new_height}")
  63. icon.resample_nearest_neighbor!(new_width, new_height)
  64. end
  65. icon
  66. end
  67. def get_sidebar_background
  68. # This is a hacky way to get the background color of the sidebar
  69. # Unfortunately, it's not possible to get the value of a SCSS variable from a Thor task
  70. # Because hard-coding the value is even worse, we extract it using some regex
  71. path = 'assets/stylesheets/global/_variables-dark.scss'
  72. regex = /\$sidebarBackground:\s+([^;]+);/
  73. ChunkyPNG::Color.parse(File.read(path)[regex, 1])
  74. end
  75. def needs_dark_icon_fix(icon, bg_color)
  76. # Determine whether the icon needs to be grayscaled if the user has enabled the dark theme
  77. # The logic comes from https://www.w3.org/TR/2008/REC-WCAG20-20081211/#visual-audio-contrast
  78. contrast = icon.pixels.map do |pixel|
  79. get_contrast(bg_color, pixel)
  80. end
  81. contrast.max < 7
  82. end
  83. def get_contrast(base, other)
  84. l1 = get_luminance(base) + 0.05
  85. l2 = get_luminance(other) + 0.05
  86. ratio = l1 / l2
  87. l2 > l1 ? 1 / ratio : ratio
  88. end
  89. def get_luminance(color)
  90. rgb = [
  91. ChunkyPNG::Color.r(color).to_f,
  92. ChunkyPNG::Color.g(color).to_f,
  93. ChunkyPNG::Color.b(color).to_f
  94. ]
  95. rgb.map! do |value|
  96. value /= 255
  97. value < 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
  98. end
  99. 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
  100. end
  101. def generate_spritesheet(size, items_with_icons, output_path, &item_to_icon)
  102. logger.info("Generating spritesheet #{output_path} with icons of size #{size} x #{size}")
  103. icons_per_row = Math.sqrt(items_with_icons.length).ceil
  104. spritesheet = ChunkyPNG::Image.new(size * icons_per_row, size * icons_per_row)
  105. items_with_icons.each do |item|
  106. icon = item_to_icon.call(item)
  107. # Calculate the base coordinates
  108. base_x = item[:col] * size
  109. base_y = item[:row] * size
  110. # Center the icon if it's not a perfect rectangle
  111. x = base_x + ((size - icon.width) / 2).floor
  112. y = base_y + ((size - icon.height) / 2).floor
  113. spritesheet.compose!(icon, x, y)
  114. end
  115. FileUtils.mkdir_p(File.dirname(output_path))
  116. spritesheet.save(output_path)
  117. end
  118. def save_manifest(items, icons_per_row, path)
  119. logger.info("Saving spritesheet details to #{path}")
  120. FileUtils.mkdir_p(File.dirname(path))
  121. # Only save the details that the scss file needs
  122. manifest_items = items.map do |item|
  123. {
  124. :type => item[:type],
  125. :row => item[:row],
  126. :col => item[:col],
  127. :dark_icon_fix => item[:dark_icon_fix]
  128. }
  129. end
  130. manifest = {:icons_per_row => icons_per_row, :items => manifest_items}
  131. File.open(path, 'w') do |f|
  132. f.write(JSON.generate(manifest))
  133. end
  134. end
  135. def log_details(items_with_icons, icons_per_row)
  136. logger.debug("Amount of icons: #{items_with_icons.length}")
  137. logger.debug("Icons per row: #{icons_per_row}")
  138. max_type_length = items_with_icons.map {|item| item[:type].length}.max
  139. border = "+#{'-' * (max_type_length + 2)}+#{'-' * 5}+#{'-' * 8}+#{'-' * 15}+"
  140. logger.debug(border)
  141. logger.debug("| #{'Type'.ljust(max_type_length)} | Row | Column | Dark icon fix |")
  142. logger.debug(border)
  143. items_with_icons.each do |item|
  144. logger.debug("| #{item[:type].ljust(max_type_length)} | #{item[:row].to_s.ljust(3)} | #{item[:col].to_s.ljust(6)} | #{(item[:dark_icon_fix] ? 'Yes' : 'No').ljust(13)} |")
  145. end
  146. logger.debug(border)
  147. end
  148. def logger
  149. @logger ||= Logger.new($stdout).tap do |logger|
  150. logger.level = options[:verbose] ? Logger::DEBUG : Logger::INFO
  151. logger.formatter = proc {|severity, datetime, progname, msg| "#{msg}\n"}
  152. end
  153. end
  154. end