sprites.thor 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  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. require 'image_optim'
  10. require 'terminal-table'
  11. super
  12. end
  13. desc 'generate [--remove-public-icons] [--disable-optimization] [--verbose]', 'Generate the documentation icon spritesheets'
  14. option :remove_public_icons, type: :boolean, desc: 'Remove public/icons after generating the spritesheets'
  15. option :disable_optimization, type: :boolean, desc: 'Disable optimizing the spritesheets with OptiPNG'
  16. option :verbose, type: :boolean
  17. def generate
  18. items = get_items
  19. items_with_icons = items.select {|item| item[:has_icons]}
  20. items_without_icons = items.select {|item| !item[:has_icons]}
  21. icons_per_row = Math.sqrt(items_with_icons.length).ceil
  22. bg_color = get_sidebar_background
  23. items_with_icons.each_with_index do |item, index|
  24. item[:row] = (index / icons_per_row).floor
  25. item[:col] = index - item[:row] * icons_per_row
  26. item[:icon_16] = get_icon(item[:path_16], 16)
  27. item[:icon_32] = get_icon(item[:path_32], 32)
  28. item[:dark_icon_fix] = needs_dark_icon_fix(item[:icon_32], bg_color)
  29. end
  30. return unless items_with_icons.length > 0
  31. log_details(items_with_icons, icons_per_row) if options[:verbose]
  32. generate_spritesheet(16, items_with_icons) {|item| item[:icon_16]}
  33. generate_spritesheet(32, items_with_icons) {|item| item[:icon_32]}
  34. unless options[:disable_optimization]
  35. optimize_spritesheet(get_output_path(16))
  36. optimize_spritesheet(get_output_path(32))
  37. end
  38. # Add Mongoose's icon details to docs without custom icons
  39. default_item = items_with_icons.find {|item| item[:type] == 'mongoose'}
  40. items_without_icons.each do |item|
  41. item[:row] = default_item[:row]
  42. item[:col] = default_item[:col]
  43. item[:dark_icon_fix] = default_item[:dark_icon_fix]
  44. end
  45. save_manifest(items, icons_per_row, 'assets/images/sprites/docs.json')
  46. if options[:remove_public_icons]
  47. logger.info('Removing public/icons')
  48. FileUtils.rm_rf('public/icons')
  49. end
  50. end
  51. private
  52. def get_items
  53. items = Docs.all.map do |doc|
  54. base_path = "public/icons/docs/#{doc.slug}"
  55. {
  56. :type => doc.slug,
  57. :path_16 => "#{base_path}/16.png",
  58. :path_32 => "#{base_path}/16@2x.png"
  59. }
  60. end
  61. # Checking paths against an array of possible paths is faster than 200+ File.exist? calls
  62. files = Dir.glob('public/icons/docs/**/*.png')
  63. items.each do |item|
  64. item[:has_icons] = files.include?(item[:path_16]) && files.include?(item[:path_32])
  65. end
  66. end
  67. def get_icon(path, max_size)
  68. icon = ChunkyPNG::Image.from_file(path)
  69. # Check if the icon is too big
  70. # If it is, resize the image without changing the aspect ratio
  71. if icon.width > max_size || icon.height > max_size
  72. ratio = icon.width.to_f / icon.height
  73. new_width = (icon.width >= icon.height ? max_size : max_size * ratio).floor
  74. new_height = (icon.width >= icon.height ? max_size / ratio : max_size).floor
  75. 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}")
  76. icon.resample_nearest_neighbor!(new_width, new_height)
  77. end
  78. icon
  79. end
  80. def get_sidebar_background
  81. # This is a hacky way to get the background color of the sidebar
  82. # Unfortunately, it's not possible to get the value of a SCSS variable from a Thor task
  83. # Because hard-coding the value is even worse, we extract it using some regex
  84. path = 'assets/stylesheets/global/_variables-dark.scss'
  85. regex = /--sidebarBackground:\s+([^;]+);/
  86. ChunkyPNG::Color.parse(File.read(path)[regex, 1])
  87. end
  88. def needs_dark_icon_fix(icon, bg_color)
  89. # Determine whether the icon needs to be grayscaled if the user has enabled the dark theme
  90. # The logic is roughly based on https://www.w3.org/TR/2008/REC-WCAG20-20081211/#visual-audio-contrast
  91. contrast = icon.pixels.select {|pixel| ChunkyPNG::Color.a(pixel) > 0}.map do |pixel|
  92. get_contrast(bg_color, pixel)
  93. end
  94. avg = contrast.reduce(:+) / contrast.size.to_f
  95. avg < 2.5
  96. end
  97. def get_contrast(base, other)
  98. # Calculating the contrast ratio as described in the WCAG 2.0:
  99. # https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
  100. l1 = get_luminance(base) + 0.05
  101. l2 = get_luminance(other) + 0.05
  102. ratio = l1 / l2
  103. l2 > l1 ? 1 / ratio : ratio
  104. end
  105. def get_luminance(color)
  106. rgb = [
  107. ChunkyPNG::Color.r(color).to_f,
  108. ChunkyPNG::Color.g(color).to_f,
  109. ChunkyPNG::Color.b(color).to_f
  110. ]
  111. # Calculating the relative luminance as described in the WCAG 2.0:
  112. # https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
  113. rgb.map! do |value|
  114. value /= 255
  115. value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
  116. end
  117. 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
  118. end
  119. def generate_spritesheet(size, items_with_icons, &item_to_icon)
  120. output_path = get_output_path(size)
  121. logger.info("Generating spritesheet to #{output_path} with icons of size #{size} x #{size}")
  122. icons_per_row = Math.sqrt(items_with_icons.length).ceil
  123. spritesheet = ChunkyPNG::Image.new(size * icons_per_row, size * icons_per_row)
  124. items_with_icons.each do |item|
  125. icon = item_to_icon.call(item)
  126. # Calculate the base coordinates
  127. base_x = item[:col] * size
  128. base_y = item[:row] * size
  129. # Center the icon if it's not a perfect rectangle
  130. x = base_x + ((size - icon.width) / 2).floor
  131. y = base_y + ((size - icon.height) / 2).floor
  132. spritesheet.compose!(icon, x, y)
  133. end
  134. FileUtils.mkdir_p(File.dirname(output_path))
  135. spritesheet.save(output_path)
  136. end
  137. def optimize_spritesheet(path)
  138. logger.info("Optimizing spritesheet at #{path}")
  139. image_optim.optimize_image!(path)
  140. end
  141. def save_manifest(items, icons_per_row, path)
  142. logger.info("Saving spritesheet details to #{path}")
  143. FileUtils.mkdir_p(File.dirname(path))
  144. # Only save the details that the scss file needs
  145. manifest_items = items.map do |item|
  146. {
  147. :type => item[:type],
  148. :row => item[:row],
  149. :col => item[:col],
  150. :dark_icon_fix => item[:dark_icon_fix]
  151. }
  152. end
  153. manifest = {:icons_per_row => icons_per_row, :items => manifest_items}
  154. File.open(path, 'w') do |f|
  155. f.write(JSON.generate(manifest))
  156. end
  157. end
  158. def log_details(items_with_icons, icons_per_row)
  159. title = "#{items_with_icons.length} items with icons (#{icons_per_row} per row)"
  160. headings = ['Type', 'Row', 'Column', "Dark icon fix (#{items_with_icons.count {|item| item[:dark_icon_fix]}})"]
  161. rows = items_with_icons.map {|item| [item[:type], item[:row], item[:col], item[:dark_icon_fix] ? 'Yes' : 'No']}
  162. table = Terminal::Table.new :title => title, :headings => headings, :rows => rows
  163. puts table
  164. end
  165. def get_output_path(size)
  166. "assets/images/sprites/docs#{size == 32 ? '@2x' : ''}.png"
  167. end
  168. def image_optim
  169. @image_optim ||= ImageOptim.new(
  170. :config_paths => [],
  171. :advpng => false,
  172. :gifsicle => false,
  173. :jhead => false,
  174. :jpegoptim => false,
  175. :jpegrecompress => false,
  176. :jpegtran => false,
  177. :pngcrush => false,
  178. :pngout => false,
  179. :pngquant => false,
  180. :svgo => false,
  181. :optipng => {
  182. :level => 7,
  183. },
  184. )
  185. end
  186. def logger
  187. @logger ||= Logger.new($stdout).tap do |logger|
  188. logger.level = options[:verbose] ? Logger::DEBUG : Logger::INFO
  189. logger.formatter = proc {|severity, datetime, progname, msg| "#{msg}\n"}
  190. end
  191. end
  192. end