浏览代码

Automatically generate spritesheets

Jasper van Merle 7 年之前
父节点
当前提交
90123a3679

+ 2 - 0
.gitignore

@@ -8,3 +8,5 @@ public/fonts
 public/docs/**/*
 !public/docs/docs.json
 !public/docs/**/index.json
+log/
+assets/images/sprites

+ 2 - 0
Gemfile

@@ -18,6 +18,8 @@ group :app do
   gem 'browser'
   gem 'sass'
   gem 'coffee-script'
+  gem 'chunky_png'
+  gem 'sprockets-sass'
 end
 
 group :production do

+ 6 - 1
Gemfile.lock

@@ -12,6 +12,7 @@ GEM
       erubi (>= 1.0.0)
       rack (>= 0.9.0)
     browser (2.5.3)
+    chunky_png (1.3.10)
     coderay (1.1.2)
     coffee-script (2.4.1)
       coffee-script-source
@@ -93,6 +94,8 @@ GEM
       rack (> 1, < 3)
     sprockets-helpers (1.2.1)
       sprockets (>= 2.2)
+    sprockets-sass (2.0.0.beta2)
+      sprockets (>= 2.0, < 4.0)
     strings (0.1.1)
       unicode-display_width (~> 1.3.0)
       unicode_utils (~> 1.4.0)
@@ -127,6 +130,7 @@ DEPENDENCIES
   activesupport (~> 5.2)
   better_errors
   browser
+  chunky_png
   coffee-script
   erubi
   html-pipeline
@@ -146,6 +150,7 @@ DEPENDENCIES
   sinatra-contrib
   sprockets
   sprockets-helpers
+  sprockets-sass
   thin
   thor
   tty-pager
@@ -158,4 +163,4 @@ RUBY VERSION
    ruby 2.5.1p57
 
 BUNDLED WITH
-   1.16.1
+   1.16.4

二进制
assets/images/docs-1.png


二进制
assets/images/docs-1@2x.png


二进制
assets/images/docs-2.png


二进制
assets/images/docs-2@2x.png


+ 3 - 4
assets/stylesheets/application-dark.css.scss

@@ -1,7 +1,6 @@
-//= depend_on docs-1.png
-//= depend_on docs-1@2x.png
-//= depend_on docs-2.png
-//= depend_on docs-2@2x.png
+//= depend_on sprites/docs.png
+//= depend_on sprites/docs@2x.png
+//= depend_on sprites/docs.json
 
 /*!
  * Copyright 2013-2018 Thibaut Courouble and other contributors

+ 3 - 4
assets/stylesheets/application.css.scss

@@ -1,7 +1,6 @@
-//= depend_on docs-1.png
-//= depend_on docs-1@2x.png
-//= depend_on docs-2.png
-//= depend_on docs-2@2x.png
+//= depend_on sprites/docs.png
+//= depend_on sprites/docs@2x.png
+//= depend_on sprites/docs.json
 
 /*!
  * Copyright 2013-2018 Thibaut Courouble and other contributors

+ 0 - 180
assets/stylesheets/global/_icons.scss

@@ -1,180 +0,0 @@
-%svg-icon {
-  display: inline-block;
-  vertical-align: top;
-  width: 1rem;
-  height: 1rem;
-  pointer-events: none;
-  fill: currentColor;
-}
-
-%doc-icon {
-  content: '';
-  display: block;
-  width: 1rem;
-  height: 1rem;
-  background-image: image-url('docs-1.png');
-  background-size: 10rem 10rem;
-}
-
-%doc-icon-2 { background-image: image-url('docs-2.png') !important; }
-
-@media (-webkit-min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) {
-  %doc-icon { background-image: image-url('docs-1@2x.png'); }
-  %doc-icon-2 { background-image: image-url('docs-2@2x.png') !important; }
-}
-
-%darkIconFix {
-  @if $style == 'dark' {
-    filter: invert(100%) grayscale(100%);
-    -webkit-filter: invert(100%) grayscale(100%);
-  }
-}
-
-._icon-jest:before          { background-position: 0 0; }
-._icon-liquid:before        { background-position: -1rem 0; }
-._icon-openjdk:before       { background-position: -2rem 0; }
-._icon-codeceptjs:before    { background-position: -3rem 0; }
-._icon-codeception:before   { background-position: -4rem 0; }
-._icon-sqlite:before        { background-position: -5rem 0; @extend %darkIconFix !optional; }
-._icon-async:before         { background-position: -6rem 0; @extend %darkIconFix !optional; }
-._icon-http:before          { background-position: -7rem 0; @extend %darkIconFix !optional; }
-._icon-jquery:before        { background-position: -8rem 0; @extend %darkIconFix !optional; }
-._icon-underscore:before    { background-position: -9rem 0; @extend %darkIconFix !optional; }
-._icon-html:before          { background-position: 0 -1rem; }
-._icon-css:before           { background-position: -1rem -1rem; }
-._icon-dom:before           { background-position: -2rem -1rem; }
-._icon-dom_events:before    { background-position: -3rem -1rem; }
-._icon-javascript:before    { background-position: -4rem -1rem; }
-._icon-backbone:before      { background-position: -5rem -1rem; @extend %darkIconFix !optional; }
-._icon-node:before,
-._icon-node_lts:before      { background-position: -6rem -1rem; }
-._icon-sass:before          { background-position: -7rem -1rem; }
-._icon-less:before          { background-position: -8rem -1rem; }
-._icon-angularjs:before     { background-position: -9rem -1rem; }
-._icon-coffeescript:before  { background-position: 0 -2rem; @extend %darkIconFix !optional; }
-._icon-ember:before         { background-position: -1rem -2rem; }
-._icon-yarn:before          { background-position: -2rem -2rem; }
-._icon-immutable:before     { background-position: -3rem -2rem; @extend %darkIconFix !optional; }
-._icon-jqueryui:before      { background-position: -4rem -2rem; }
-._icon-jquerymobile:before  { background-position: -5rem -2rem; }
-._icon-lodash:before        { background-position: -6rem -2rem; }
-._icon-php:before           { background-position: -7rem -2rem; }
-._icon-ruby:before,
-._icon-minitest:before      { background-position: -8rem -2rem; }
-._icon-rails:before         { background-position: -9rem -2rem; }
-._icon-python:before,
-._icon-python2:before       { background-position: 0 -3rem; }
-._icon-git:before           { background-position: -1rem -3rem; }
-._icon-redis:before         { background-position: -2rem -3rem; }
-._icon-postgresql:before    { background-position: -3rem -3rem; }
-._icon-d3:before            { background-position: -4rem -3rem; }
-._icon-knockout:before      { background-position: -5rem -3rem; }
-._icon-moment:before        { background-position: -6rem -3rem; @extend %darkIconFix !optional; }
-._icon-c:before             { background-position: -7rem -3rem; }
-._icon-statsmodels:before   { background-position: -8rem -3rem; }
-._icon-yii:before,
-._icon-yii1:before          { background-position: -9rem -3rem; }
-._icon-cpp:before           { background-position: 0 -4rem; }
-._icon-go:before            { background-position: -1rem -4rem; }
-._icon-express:before       { background-position: -2rem -4rem; }
-._icon-grunt:before         { background-position: -3rem -4rem; }
-._icon-rust:before          { background-position: -4rem -4rem; @extend %darkIconFix !optional; }
-._icon-laravel:before       { background-position: -5rem -4rem; }
-._icon-haskell:before       { background-position: -6rem -4rem; }
-._icon-requirejs:before     { background-position: -7rem -4rem; }
-._icon-chai:before          { background-position: -8rem -4rem; }
-._icon-sinon:before         { background-position: -9rem -4rem; }
-._icon-cordova:before       { background-position: 0 -5rem; }
-._icon-markdown:before      { background-position: -1rem -5rem; @extend %darkIconFix !optional; }
-._icon-django:before        { background-position: -2rem -5rem; }
-._icon-xslt_xpath:before    { background-position: -3rem -5rem; }
-._icon-nginx:before,
-._icon-nginx_lua_module:before { background-position: -4rem -5rem; }
-._icon-svg:before           { background-position: -5rem -5rem; }
-._icon-marionette:before    { background-position: -6rem -5rem; }
-._icon-jsdoc:before,
-._icon-koa:before,
-._icon-graphite:before,
-._icon-mongoose:before      { background-position: -7rem -5rem; }
-._icon-phpunit:before       { background-position: -8rem -5rem; }
-._icon-nokogiri:before      { background-position: -9rem -5rem; @extend %darkIconFix !optional; }
-._icon-rethinkdb:before     { background-position: 0 -6rem; }
-._icon-react:before         { background-position: -1rem -6rem; }
-._icon-socketio:before      { background-position: -2rem -6rem; }
-._icon-modernizr:before     { background-position: -3rem -6rem; }
-._icon-bower:before         { background-position: -4rem -6rem; }
-._icon-fish:before          { background-position: -5rem -6rem; @extend %darkIconFix !optional; }
-._icon-scikit_image:before  { background-position: -6rem -6rem; }
-._icon-twig:before          { background-position: -7rem -6rem; }
-._icon-pandas:before        { background-position: -8rem -6rem; }
-._icon-scikit_learn:before  { background-position: -9rem -6rem; }
-._icon-bottle:before        { background-position: 0 -7rem; }
-._icon-docker:before        { background-position: -1rem -7rem; }
-._icon-cakephp:before       { background-position: -2rem -7rem; }
-._icon-lua:before           { background-position: -3rem -7rem; @extend %darkIconFix !optional; }
-._icon-clojure:before       { background-position: -4rem -7rem; }
-._icon-symfony:before       { background-position: -5rem -7rem; }
-._icon-mocha:before         { background-position: -6rem -7rem; }
-._icon-meteor:before        { background-position: -7rem -7rem; @extend %darkIconFix !optional; }
-._icon-npm:before           { background-position: -8rem -7rem; }
-._icon-apache_http_server:before { background-position: -9rem -7rem; }
-._icon-drupal:before        { background-position: 0 -8rem; }
-._icon-webpack:before       { background-position: -1rem -8rem; }
-._icon-phaser:before        { background-position: -2rem -8rem; }
-._icon-vue:before           { background-position: -3rem -8rem; }
-._icon-opentsdb:before      { background-position: -4rem -8rem; }
-._icon-q:before             { background-position: -5rem -8rem; }
-._icon-crystal:before       { background-position: -6rem -8rem; @extend %darkIconFix !optional; }
-._icon-julia:before         { background-position: -7rem -8rem; @extend %darkIconFix !optional; }
-._icon-redux:before         { background-position: -8rem -8rem; @extend %darkIconFix !optional; }
-._icon-bootstrap:before     { background-position: -9rem -8rem; }
-._icon-react_native:before  { background-position: 0 -9rem; }
-._icon-phalcon:before       { background-position: -1rem -9rem; }
-._icon-matplotlib:before    { background-position: -2rem -9rem; }
-._icon-cmake:before         { background-position: -3rem -9rem; }
-._icon-elixir:before        { background-position: -4rem -9rem; @extend %darkIconFix !optional; }
-._icon-vagrant:before       { background-position: -5rem -9rem; }
-._icon-dojo:before          { background-position: -6rem -9rem; }
-._icon-flow:before          { background-position: -7rem -9rem; }
-._icon-relay:before         { background-position: -8rem -9rem; }
-._icon-phoenix:before       { background-position: -9rem -9rem; }
-
-._icon-tcl_tk:before        { background-position: 0 0; @extend %doc-icon-2; }
-._icon-erlang:before        { background-position: -1rem 0; @extend %doc-icon-2; }
-._icon-chef:before          { background-position: -2rem 0; @extend %doc-icon-2; }
-._icon-ramda:before         { background-position: -3rem 0; @extend %doc-icon-2; @extend %darkIconFix !optional; }
-._icon-codeigniter:before   { background-position: -4rem 0; @extend %doc-icon-2; @extend %darkIconFix !optional; }
-._icon-influxdata:before    { background-position: -5rem 0; @extend %doc-icon-2; @extend %darkIconFix !optional; }
-._icon-tensorflow:before    { background-position: -6rem 0; @extend %doc-icon-2; }
-._icon-haxe:before          { background-position: -7rem 0; @extend %doc-icon-2; }
-._icon-ansible:before       { background-position: -8rem 0; @extend %doc-icon-2; @extend %darkIconFix !optional; }
-._icon-typescript:before    { background-position: -9rem 0; @extend %doc-icon-2; }
-._icon-browser_support_tables:before { background-position: 0rem -1rem; @extend %doc-icon-2; }
-._icon-gnu_fortran:before   { background-position: -1rem -1rem; @extend %doc-icon-2; }
-._icon-gcc:before           { background-position: -2rem -1rem; @extend %doc-icon-2; }
-._icon-perl:before          { background-position: -3rem -1rem; @extend %doc-icon-2; }
-._icon-apache_pig:before    { background-position: -4rem -1rem; @extend %doc-icon-2; }
-._icon-numpy:before         { background-position: -5rem -1rem; @extend %doc-icon-2; }
-._icon-kotlin:before        { background-position: -6rem -1rem; @extend %doc-icon-2; }
-._icon-padrino:before       { background-position: -7rem -1rem; @extend %doc-icon-2; }
-._icon-angular:before       { background-position: -8rem -1rem; @extend %doc-icon-2; }
-._icon-love:before          { background-position: -9rem -1rem; @extend %doc-icon-2; }
-._icon-jasmine:before       { background-position: 0 -2rem; @extend %doc-icon-2; }
-._icon-pug:before           { background-position: -1rem -2rem; @extend %doc-icon-2; }
-._icon-electron:before      { background-position: -2rem -2rem; @extend %doc-icon-2; }
-._icon-falcon:before        { background-position: -3rem -2rem; @extend %doc-icon-2; }
-._icon-godot:before         { background-position: -4rem -2rem; @extend %doc-icon-2; }
-._icon-nim:before           { background-position: -5rem -2rem; @extend %doc-icon-2; @extend %darkIconFix !optional; }
-._icon-vulkan:before        { background-position: -6rem -2rem; @extend %doc-icon-2; @extend %darkIconFix !optional; }
-._icon-d:before             { background-position: -7rem -2rem; @extend %doc-icon-2; }
-._icon-bluebird:before      { background-position: -8rem -2rem; @extend %doc-icon-2; }
-._icon-eslint:before        { background-position: -9rem -2rem; @extend %doc-icon-2; }
-._icon-homebrew:before      { background-position: 0 -3rem; @extend %doc-icon-2; }
-._icon-jekyll:before        { background-position: -1rem -3rem; @extend %doc-icon-2; }
-._icon-babel:before         { background-position: -2rem -3rem; @extend %doc-icon-2; }
-._icon-leaflet:before       { background-position: -3rem -3rem; @extend %doc-icon-2; }
-._icon-terraform:before     { background-position: -4rem -3rem; @extend %doc-icon-2; }
-._icon-pygame:before        { background-position: -5rem -3rem; @extend %doc-icon-2; }
-._icon-bash:before          { background-position: -6rem -3rem; @extend %doc-icon-2; }
-._icon-dart:before          { background-position: -7rem -3rem; @extend %doc-icon-2; }
-._icon-qt:before            { background-position: -8rem -3rem; @extend %doc-icon-2; }

+ 43 - 0
assets/stylesheets/global/_icons.scss.erb

@@ -0,0 +1,43 @@
+<% manifest = JSON.parse(File.read('assets/images/sprites/docs.json')) %>
+
+%svg-icon {
+  display: inline-block;
+  vertical-align: top;
+  width: 1rem;
+  height: 1rem;
+  pointer-events: none;
+  fill: currentColor;
+}
+
+%doc-icon {
+  content: '';
+  display: block;
+  width: 1rem;
+  height: 1rem;
+  background-image: image-url('sprites/docs.png');
+  background-size: <%= manifest['icons_per_row'] %>rem <%= manifest['icons_per_row'] %>rem;
+}
+
+@media (-webkit-min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) {
+  %doc-icon { background-image: image-url('sprites/docs@2x.png'); }
+}
+
+%darkIconFix {
+  @if $style == 'dark' {
+    filter: invert(100%) grayscale(100%);
+    -webkit-filter: invert(100%) grayscale(100%);
+  }
+}
+
+<%=
+  items = []
+
+  manifest['icons'].each do |icon|
+    rules = []
+    rules << "background-position: -#{icon['col']}rem -#{icon['row']}rem;"
+    rules << "@extend %darkIconFix !optional;" if icon['dark_icon_fix']
+    items << "._icon-#{icon['type']}:before { #{rules.join(' ')} }"
+  end
+
+  items.join('')
+ %>

+ 5 - 0
lib/app.rb

@@ -48,6 +48,11 @@ class App < Sinatra::Application
   end
 
   configure :test, :development do
+    require 'thor'
+    load 'tasks/sprites.thor'
+
+    SpritesCLI.new.invoke(:generate)
+
     require 'active_support/per_thread_registry'
     require 'active_support/cache'
     sprockets.cache = ActiveSupport::Cache.lookup_store :file_store, root.join('tmp', 'cache', 'assets', environment.to_s)

+ 1 - 0
lib/tasks/assets.thor

@@ -14,6 +14,7 @@ class AssetsCLI < Thor
   option :keep, type: :numeric, default: 0, desc: 'Number of old assets to keep'
   option :verbose, type: :boolean
   def compile
+    invoke 'sprites:generate', [], :verbose => options[:verbose]
     manifest.compile App.assets_compile
     manifest.clean(options[:keep]) if options[:clean]
   end

+ 185 - 0
lib/tasks/sprites.thor

@@ -0,0 +1,185 @@
+class SpritesCLI < Thor
+  def self.to_s
+    'Sprites'
+  end
+
+  def initialize(*args)
+    require 'docs'
+    require 'chunky_png'
+    require 'fileutils'
+    super
+  end
+
+  desc 'generate [--verbose]', 'Generate the documentation icon spritesheets'
+  option :verbose, type: :boolean
+  def generate
+    icons = get_icons
+    icons_per_row = Math.sqrt(icons.length).ceil
+
+    bg_color = get_sidebar_background
+
+    icons.each_with_index do |icon, index|
+      icon[:row] = (index / icons_per_row).floor
+      icon[:col] = index - icon[:row] * icons_per_row
+
+      icon[:icon_16] = get_icon(icon[:path_16], 16)
+      icon[:icon_32] = get_icon(icon[:path_32], 32)
+
+      icon[:dark_icon_fix] = needs_dark_icon_fix(icon[:icon_32], bg_color)
+    end
+
+    log_details(icons, icons_per_row)
+
+    generate_spritesheet(16, icons, 'assets/images/sprites/docs.png') {|icon| icon[:icon_16]}
+    generate_spritesheet(32, icons, 'assets/images/sprites/docs@2x.png') {|icon| icon[:icon_32]}
+
+    save_manifest(icons, icons_per_row, 'assets/images/sprites/docs.json')
+  end
+
+  private
+
+  def get_icons
+    items = Docs.all.map do |doc|
+      base_path = "public/icons/docs/#{doc.slug}"
+      {
+        :type => doc.slug,
+        :path_16 => "#{base_path}/16.png",
+        :path_32 => "#{base_path}/16@2x.png"
+      }
+    end
+
+    # Checking paths against an array of possible paths is faster than 200+ File.exist? calls
+    files = Dir.glob('public/icons/docs/**/*.png')
+    items.select {|item| files.include?(item[:path_16]) && files.include?(item[:path_32])}
+  end
+
+  def get_icon(path, max_size)
+    icon = ChunkyPNG::Image.from_file(path)
+
+    # Check if the icon is too big
+    # If it is, resize the image without changing the aspect ratio
+    if icon.width > max_size || icon.height > max_size
+      ratio = icon.width.to_f / icon.height
+      new_width = (icon.width >= icon.height ? max_size : max_size * ratio).floor
+      new_height = (icon.width >= icon.height ? max_size / ratio : max_size).floor
+
+      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}")
+
+      icon.resample_nearest_neighbor!(new_width, new_height)
+    end
+
+    icon
+  end
+
+  def get_sidebar_background
+    # This is a hacky way to get the background color of the sidebar
+    # Unfortunately, it's not possible to get the value of a SCSS variable from a Thor task
+    # Because hard-coding the value is even worse, we extract it using some regex
+    path = 'assets/stylesheets/global/_variables-dark.scss'
+    regex = /\$sidebarBackground:\s+([^;]+);/
+    ChunkyPNG::Color.parse(File.read(path)[regex, 1])
+  end
+
+  def needs_dark_icon_fix(icon, bg_color)
+    # Determine whether the icon needs to be grayscaled if the user has enabled the dark theme
+    # The logic comes from https://www.w3.org/TR/2008/REC-WCAG20-20081211/#visual-audio-contrast
+    contrast = icon.pixels.map do |pixel|
+      get_contrast(bg_color, pixel)
+    end
+
+    contrast.max < 7
+  end
+
+  def get_contrast(base, other)
+    l1 = get_luminance(base) + 0.05
+    l2 = get_luminance(other) + 0.05
+    ratio = l1 / l2
+    l2 > l1 ? 1 / ratio : ratio
+  end
+
+  def get_luminance(color)
+    rgba = [
+      ChunkyPNG::Color.r(color).to_f,
+      ChunkyPNG::Color.g(color).to_f,
+      ChunkyPNG::Color.b(color).to_f,
+      ChunkyPNG::Color.a(color).to_f
+    ]
+
+    rgba.map! do |rgb|
+      rgb /= 255
+      rgb < 0.03928 ? rgb / 12.92 : ((rgb + 0.055) / 1.055) ** 2.4
+    end
+
+    0.2126 * rgba[0] + 0.7152 * rgba[1] + 0.0722 * rgba[2]
+  end
+
+  def generate_spritesheet(size, icons, output_path, &icon_to_img)
+    logger.info("Generating spritesheet #{output_path} with icons of size #{size} x #{size}")
+
+    icons_per_row = Math.sqrt(icons.length).ceil
+    spritesheet = ChunkyPNG::Image.new(size * icons_per_row, size * icons_per_row)
+
+    icons.each do |icon|
+      img = icon_to_img.call(icon)
+
+      # Calculate the base coordinates
+      base_x = icon[:col] * size
+      base_y = icon[:row] * size
+
+      # Center the icon if it's not a perfect rectangle
+      x = base_x + ((size - img.width) / 2).floor
+      y = base_y + ((size - img.height) / 2).floor
+
+      spritesheet.compose!(img, x, y)
+    end
+
+    FileUtils.mkdir_p(File.dirname(output_path))
+    spritesheet.save(output_path)
+  end
+
+  def save_manifest(icons, icons_per_row, path)
+    logger.info("Saving spritesheet details to #{path}")
+
+    FileUtils.mkdir_p(File.dirname(path))
+
+    # Only save the details that the scss file needs
+    manifest_icons = icons.map do |icon|
+      {
+        :type => icon[:type],
+        :row => icon[:row],
+        :col => icon[:col],
+        :dark_icon_fix => icon[:dark_icon_fix]
+      }
+    end
+
+    manifest = {:icons_per_row => icons_per_row, :icons => manifest_icons}
+
+    File.open(path, 'w') do |f|
+      f.write(JSON.generate(manifest))
+    end
+  end
+
+  def log_details(icons, icons_per_row)
+    logger.debug("Amount of icons: #{icons.length}")
+    logger.debug("Icons per row: #{icons_per_row}")
+
+    max_type_length = icons.map { |icon| icon[:type].length }.max
+    border = "+#{'-' * (max_type_length + 2)}+#{'-' * 5}+#{'-' * 8}+#{'-' * 15}+"
+    logger.debug(border)
+    logger.debug("| #{'Type'.ljust(max_type_length)} | Row | Column | Dark icon fix |")
+    logger.debug(border)
+
+    icons.each do |icon|
+      logger.debug("| #{icon[:type].ljust(max_type_length)} | #{icon[:row].to_s.ljust(3)} | #{icon[:col].to_s.ljust(6)} | #{(icon[:dark_icon_fix] ? 'Yes' : 'No').ljust(13)} |")
+    end
+
+    logger.debug(border)
+  end
+
+  def logger
+    @logger ||= Logger.new($stdout).tap do |logger|
+      logger.level = options[:verbose] ? Logger::DEBUG : Logger::INFO
+      logger.formatter = proc { |severity, datetime, progname, msg| "#{msg}\n" }
+    end
+  end
+end

二进制
public/icons/docs-1.pxm


二进制
public/icons/docs-1@2x.pxm


二进制
public/icons/docs-2.pxm


二进制
public/icons/docs-2@2x.pxm


二进制
public/icons/docs/bluebird/16@2x.png