vitals_image 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/Rakefile +19 -0
  4. data/app/assets/config/vitals_image_manifest.js +1 -0
  5. data/app/assets/stylesheets/vitals_image/application.css +15 -0
  6. data/app/controllers/vitals_image/application_controller.rb +6 -0
  7. data/app/helpers/vitals_image/application_helper.rb +6 -0
  8. data/app/helpers/vitals_image/tag_helper.rb +50 -0
  9. data/app/jobs/vitals_image/analyze_job.rb +11 -0
  10. data/app/jobs/vitals_image/application_job.rb +6 -0
  11. data/app/models/vitals_image/application_record.rb +7 -0
  12. data/app/models/vitals_image/source.rb +9 -0
  13. data/app/views/layouts/vitals_image/application.html.erb +15 -0
  14. data/config/routes.rb +4 -0
  15. data/db/migrate/20210502132155_create_vitals_image_sources.rb +15 -0
  16. data/lib/tasks/vitals_image_tasks.rake +5 -0
  17. data/lib/vitals_image.rb +34 -0
  18. data/lib/vitals_image/analyzer.rb +22 -0
  19. data/lib/vitals_image/analyzer/url.rb +54 -0
  20. data/lib/vitals_image/base.rb +21 -0
  21. data/lib/vitals_image/cache.rb +23 -0
  22. data/lib/vitals_image/core_extensions/active_storage/image_analyzer.rb +59 -0
  23. data/lib/vitals_image/core_extensions/active_storage/isolated_image_analyzer.rb +72 -0
  24. data/lib/vitals_image/engine.rb +81 -0
  25. data/lib/vitals_image/errors.rb +15 -0
  26. data/lib/vitals_image/gem_version.rb +17 -0
  27. data/lib/vitals_image/optimizer.rb +135 -0
  28. data/lib/vitals_image/optimizer/active_storage.rb +87 -0
  29. data/lib/vitals_image/optimizer/blank.rb +30 -0
  30. data/lib/vitals_image/optimizer/url.rb +45 -0
  31. data/lib/vitals_image/test_case.rb +9 -0
  32. data/lib/vitals_image/version.rb +10 -0
  33. metadata +273 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 84a3011e3da96900b519bb6ce6055c82fcbcca849afc9848d3a1d997132a5b32
4
+ data.tar.gz: 744defafc94c3ec847f170fd77b38e714ff3b6c1e9960eb921b3464ae8726efd
5
+ SHA512:
6
+ metadata.gz: 0554f9fa24c1e86a49732d56ca4521df497986e45e246bf94bc278ed23ffa582968def6122627bfd8e949eef6845ab20d69ec837dbc892fd75464e5e8e250afd
7
+ data.tar.gz: 2234ac3485c84b894794ca313b4e5ca18d7ff2c9f0c7efbb5133bbda95684fc5ce81c0d28ef61c902a5c36a9ae2f85b5b9945bf12c5d61328fff01fe992fa6e6
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2021 Breno Gazzola
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
6
+
7
+ load "rails/tasks/engine.rake"
8
+ load "rails/tasks/statistics.rake"
9
+
10
+ require "bundler/gem_tasks"
11
+ require "rake/testtask"
12
+
13
+ Rake::TestTask.new(:test) do |t|
14
+ t.libs << "test"
15
+ t.pattern = "test/**/*_test.rb"
16
+ t.verbose = false
17
+ end
18
+
19
+ task default: :test
@@ -0,0 +1 @@
1
+ //= link_directory ../stylesheets/vitals_image .css
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ class ApplicationController < ActionController::Base
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ module ApplicationHelper
5
+ end
6
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ module TagHelper
5
+ def vitals_image_tag(source, options = {})
6
+ source = image_url(source) if source.is_a?(String)
7
+ optimizer = VitalsImage::Base.optimizer(source, options)
8
+
9
+ if !optimizer.variable?
10
+ vitals_image_invariable_tag(optimizer)
11
+ elsif optimizer.native_lazy_load?
12
+ vitals_image_variable_tag(optimizer)
13
+ else
14
+ vitals_image_lazy_tag(optimizer)
15
+ end
16
+ end
17
+
18
+ private
19
+ def vitals_image_invariable_tag(optimizer)
20
+ image_tag optimizer.src, optimizer.html_options
21
+ end
22
+
23
+ def vitals_image_variable_tag(optimizer)
24
+ url = vitals_image_url(optimizer.src, optimizer.html_options)
25
+ image_tag url, optimizer.html_options
26
+ end
27
+
28
+ def vitals_image_lazy_tag(optimizer)
29
+ url = vitals_image_url(optimizer.html_options["data"]["src"], optimizer.html_options)
30
+ optimizer.html_options["data"]["src"] = url
31
+
32
+ image_tag optimizer.src, optimizer.html_options
33
+ end
34
+
35
+ def vitals_image_url(source, options)
36
+ active_storage_route = options.delete("active_storage_route") || VitalsImage.active_storage_route
37
+
38
+ case active_storage_route
39
+ when :redirect
40
+ rails_storage_redirect_path(source)
41
+ when :proxy
42
+ rails_storage_proxy_path(source)
43
+ when :public
44
+ source.is_a?(ActiveStorage::VariantWithRecord) ? source.processed.url : source.url
45
+ else
46
+ url_for(source)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ class AnalyzeJob < ApplicationJob
5
+ queue_as { ActiveStorage.queues[:analysis] }
6
+
7
+ def perform(source)
8
+ VitalsImage::Base.analyzer(source).analyze
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ class ApplicationJob < ActiveJob::Base
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ class Source < ApplicationRecord
5
+ store :metadata, accessors: [ :analyzed, :width, :height ], coder: ActiveRecord::Coders::JSON, default: "{ analyzed: false }"
6
+
7
+ after_create -> { AnalyzeJob.perform_later(self) }
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Vitals image</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= stylesheet_link_tag "vitals_image/application", media: "all" %>
9
+ </head>
10
+ <body>
11
+
12
+ <%= yield %>
13
+
14
+ </body>
15
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ VitalsImage::Engine.routes.draw do
4
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateVitalsImageSources < ActiveRecord::Migration[6.1]
4
+ def change
5
+ create_table :vitals_image_sources do |t|
6
+ t.string :key, null: false
7
+ t.string :content_type
8
+ t.text :metadata
9
+
10
+ t.timestamps
11
+
12
+ t.index [ :key ], unique: true
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+ # desc "Explaining what the task does"
3
+ # task :vitals_image do
4
+ # # Task goes here
5
+ # end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "vitals_image/version"
4
+ require "vitals_image/engine"
5
+
6
+ module VitalsImage
7
+ extend ActiveSupport::Autoload
8
+
9
+ autoload :FixtureSet
10
+
11
+
12
+ mattr_accessor :logger
13
+ mattr_accessor :optimizers
14
+ mattr_accessor :analyzers
15
+ mattr_accessor :image_library
16
+
17
+ mattr_accessor :mobile_width
18
+ mattr_accessor :desktop_width
19
+ mattr_accessor :resolution
20
+ mattr_accessor :lazy_loading
21
+ mattr_accessor :lazy_loading_placeholder
22
+ mattr_accessor :require_alt_attribute
23
+
24
+ mattr_accessor :replace_active_storage_analyzer
25
+ mattr_accessor :check_for_white_background
26
+
27
+ mattr_accessor :convert_to_jpeg
28
+ mattr_accessor :jpeg_conversion
29
+ mattr_accessor :jpeg_optimization
30
+ mattr_accessor :png_optimization
31
+ mattr_accessor :active_storage_route
32
+
33
+ mattr_accessor :skip_ssl_verification
34
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ # This is an abstract base class for dimension calculators
5
+ class Analyzer
6
+ attr_reader :source
7
+
8
+ # Implement this method in a concrete subclass. Have it return true when given a source from which
9
+ # it can calculate dimensions
10
+ def self.accept?(source)
11
+ false
12
+ end
13
+
14
+ def initialize(source)
15
+ @source = source
16
+ end
17
+
18
+ def logger
19
+ VitalsImage.logger
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open-uri"
4
+
5
+ module VitalsImage
6
+ class Analyzer::Url < Analyzer
7
+ def self.accept?(source)
8
+ source.is_a?(Source)
9
+ end
10
+
11
+ def analyze
12
+ file = download
13
+ image = open(file)
14
+ mime = Marcel::MimeType.for(Pathname.new file.path)
15
+
16
+ source.update width: image.width, height: image.height, analyzed: true, content_type: mime
17
+ end
18
+
19
+ private
20
+ def open(file)
21
+ if VitalsImage.image_library == :mini_magick
22
+ MiniMagick::Image.new(file.path).tap do |image|
23
+ raise "Invalid image" unless image.valid?
24
+ end
25
+ else
26
+ Vips::Image.new_from_file(file.path, access: :sequential).tap do |image|
27
+ image.avg
28
+ end
29
+ end
30
+ end
31
+
32
+ def download
33
+ uri = URI.parse(source.key)
34
+ io = uri.open(ssl_verify_mode: ssl_verify_mode)
35
+ downloaded = Tempfile.new([File.basename(uri.path), File.extname(uri.path)])
36
+
37
+ if io.is_a?(Tempfile)
38
+ FileUtils.mv io.path, downloaded.path
39
+ else
40
+ # StringIO
41
+ File.write(downloaded.path, io.string)
42
+ end
43
+
44
+ downloaded
45
+ rescue
46
+ logger.error "Failed to download #{source.key}"
47
+ raise
48
+ end
49
+
50
+ def ssl_verify_mode
51
+ VitalsImage.skip_ssl_verification ? OpenSSL::SSL::VERIFY_NONE : nil
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ class Base
5
+ TINY_GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
6
+
7
+ def self.optimizer(object, options = {})
8
+ klass = VitalsImage.optimizers.detect { |optimizer| optimizer.accept?(object) }
9
+ raise UnoptimizableError, "Object is not supported: #{object.class}" unless klass
10
+
11
+ klass.new(object, options)
12
+ end
13
+
14
+ def self.analyzer(object)
15
+ klass = VitalsImage.analyzers.detect { |analyzer| analyzer.accept?(object) }
16
+ raise UnanalyzableError, "Object is not supported: #{object.class}" unless klass
17
+
18
+ klass.new(object)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ class Cache
5
+ include Singleton
6
+
7
+ def initialize
8
+ @store = ActiveSupport::Cache::MemoryStore.new
9
+ end
10
+
11
+ def locate(key)
12
+ source = @store.read(key)
13
+
14
+ if source.blank?
15
+ source = Source.create_or_find_by(key: key)
16
+ expires_in = source.analyzed ? nil : 1.minute
17
+ @store.write(key, source, expires_in: expires_in)
18
+ end
19
+
20
+ source
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ module CoreExtensions
5
+ module ActiveStorage
6
+ class ImageAnalyzer < ::ActiveStorage::Analyzer
7
+ def self.accept?(blob)
8
+ blob.image?
9
+ end
10
+
11
+ def metadata
12
+ read_image do |image|
13
+ if rotated_image?(image)
14
+ { width: image.height, height: image.width }
15
+ else
16
+ { width: image.width, height: image.height }
17
+ end
18
+ end
19
+ end
20
+
21
+ private
22
+ def read_image
23
+ download_blob_to_tempfile do |file|
24
+ require "ruby-vips"
25
+ image = Vips::Image.new_from_file(file.path, access: :sequential)
26
+
27
+ if valid_image?(image)
28
+ yield image
29
+ else
30
+ logger.info "Skipping image analysis because Vips doesn't support the file"
31
+ {}
32
+ end
33
+ end
34
+ rescue LoadError
35
+ logger.info "Skipping image analysis because the ruby-vips gem isn't installed"
36
+ {}
37
+ rescue Vips::Error => error
38
+ logger.error "Skipping image analysis due to an Vips error: #{error.message}"
39
+ {}
40
+ end
41
+
42
+ ROTATIONS = /Right-top|Left-bottom|Top-right|Bottom-left/
43
+
44
+ def rotated_image?(image)
45
+ ROTATIONS === image.get("exif-ifd0-Orientation")
46
+ rescue ::Vips::Error
47
+ false
48
+ end
49
+
50
+ def valid_image?(image)
51
+ image.avg
52
+ true
53
+ rescue ::Vips::Error
54
+ false
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ module CoreExtensions
5
+ module ActiveStorage
6
+ class IsolatedImageAnalyzer < ImageAnalyzer
7
+ def metadata
8
+ read_image do |image|
9
+ if rotated_image?(image)
10
+ { width: image.height, height: image.width, isolated: isolated?(image) }
11
+ else
12
+ { width: image.width, height: image.height, isolated: isolated?(image) }
13
+ end
14
+ end
15
+ end
16
+
17
+ private
18
+ def isolated?(image)
19
+ corners = extract_corner_areas(image)
20
+ colors = corners.map { |corner| primary_color_for(corner) }
21
+ colors.all? { |color| color.all? { |value| value > 250 } }
22
+ rescue
23
+ false
24
+ end
25
+
26
+ def extract_corner_areas(image)
27
+ paths = []
28
+
29
+ basename = SecureRandom.urlsafe_base64
30
+ width = image.width
31
+ height = image.height
32
+ size = 8
33
+
34
+ filename = Rails.root.join("tmp", "#{basename}.jpg")
35
+ `vips copy #{image.filename} #{filename}`
36
+
37
+ paths << Rails.root.join("tmp", "#{basename}_top_left.jpg")
38
+ `vips im_extract_area #{filename} #{paths.last} 0 0 #{size} #{size}`
39
+
40
+ paths << Rails.root.join("tmp", "#{basename}_top_right.jpg")
41
+ `vips im_extract_area #{filename} #{paths.last} #{width - size} 0 #{size} #{size}`
42
+
43
+ paths << Rails.root.join("tmp", "#{basename}_bottom_right.jpg")
44
+ `vips im_extract_area #{filename} #{paths.last} #{width - size} #{height - size} #{size} #{size}`
45
+
46
+ paths << Rails.root.join("tmp", "#{basename}_bottom_left.jpg")
47
+ `vips im_extract_area #{filename} #{paths.last} 0 #{height - size} #{size} #{size}`
48
+
49
+ paths
50
+ end
51
+
52
+ def primary_color_for(filepath)
53
+ histogram = generate_color_histogram(filepath)
54
+ sorted = sort_by_frequency(histogram)
55
+ extract_dominant_rgb(sorted)
56
+ end
57
+
58
+ def generate_color_histogram(path)
59
+ `convert #{path} +dither -colors 5 -define histogram:unique-colors=true -format "%c" histogram:info:`
60
+ end
61
+
62
+ def sort_by_frequency(histogram)
63
+ histogram.each_line.map { |line| parts = line.split(":"); [parts[0].to_i, parts[1]] }.sort_by { |line| line[0] }.reverse
64
+ end
65
+
66
+ def extract_dominant_rgb(array)
67
+ array.map { |line| line[1].match(/\(([\d.,]+)/).captures.first.split(",").take(3).map(&:to_i) }.first
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require "active_model"
5
+ require "active_record"
6
+ require "active_storage"
7
+ require "active_support"
8
+
9
+ require "marcel"
10
+ require "ruby-vips"
11
+ require "mini_magick"
12
+
13
+ require "vitals_image/analyzer"
14
+ require "vitals_image/analyzer/url"
15
+ require "vitals_image/base"
16
+ require "vitals_image/cache"
17
+ require "vitals_image/errors"
18
+ require "vitals_image/optimizer"
19
+ require "vitals_image/optimizer/blank"
20
+ require "vitals_image/optimizer/url"
21
+ require "vitals_image/optimizer/active_storage"
22
+
23
+ module VitalsImage
24
+ class Engine < ::Rails::Engine
25
+ isolate_namespace VitalsImage
26
+
27
+ config.vitals_image = ActiveSupport::OrderedOptions.new
28
+ config.vitals_image.optimizers = [VitalsImage::Optimizer::Blank, VitalsImage::Optimizer::ActiveStorage, VitalsImage::Optimizer::Url]
29
+ config.vitals_image.analyzers = [VitalsImage::Analyzer::Url]
30
+
31
+ config.eager_load_namespaces << VitalsImage
32
+
33
+ initializer "vitals_image.configs" do
34
+ config.after_initialize do |app|
35
+ VitalsImage.logger = app.config.vitals_image.logger || Rails.logger
36
+ VitalsImage.optimizers = app.config.vitals_image.optimizers || []
37
+ VitalsImage.analyzers = app.config.vitals_image.analyzers || []
38
+ VitalsImage.image_library = app.config.vitals_image.image_library || :mini_magick
39
+
40
+ VitalsImage.mobile_width = app.config.vitals_image.mobile_width || :original
41
+ VitalsImage.desktop_width = app.config.vitals_image.desktop_width || :original
42
+ VitalsImage.resolution = app.config.vitals_image.resolution || 2
43
+ VitalsImage.lazy_loading = app.config.vitals_image.lazy_loading || :native
44
+ VitalsImage.lazy_loading_placeholder = app.config.vitals_image.lazy_loading_placeholder || VitalsImage::Base::TINY_GIF
45
+ VitalsImage.require_alt_attribute = app.config.vitals_image.require_alt_attribute || false
46
+
47
+ VitalsImage.replace_active_storage_analyzer = app.config.vitals_image.replace_active_storage_analyzer || false
48
+ VitalsImage.check_for_white_background = app.config.vitals_image.check_for_white_background || false
49
+
50
+ VitalsImage.convert_to_jpeg = app.config.vitals_image.convert_to_jpeg || false
51
+ VitalsImage.jpeg_conversion = app.config.vitals_image.jpeg_conversion || { sampling_factor: "4:2:0", strip: true, interlace: "JPEG", colorspace: "sRGB", quality: 80, format: "jpg", background: :white, flatten: true, alpha: :off }
52
+ VitalsImage.jpeg_optimization = app.config.vitals_image.jpeg_optimization || { sampling_factor: "4:2:0", strip: true, interlace: "JPEG", colorspace: "sRGB", quality: 80 }
53
+ VitalsImage.png_optimization = app.config.vitals_image.png_optimization || { strip: true, quality: 00 }
54
+ VitalsImage.active_storage_route = app.config.vitals_image.png_optimization || :inherited
55
+
56
+ VitalsImage.skip_ssl_verification = app.config.vitals_image.skip_ssl_verification || false
57
+ end
58
+ end
59
+
60
+ initializer "vitals_image.core_extensions" do
61
+ require_relative "core_extensions/active_storage/image_analyzer"
62
+ require_relative "core_extensions/active_storage/isolated_image_analyzer"
63
+
64
+ config.after_initialize do |app|
65
+ if VitalsImage.check_for_white_background
66
+ app.config.active_storage.analyzers.delete ActiveStorage::Analyzer::ImageAnalyzer
67
+ app.config.active_storage.analyzers.prepend CoreExtensions::ActiveStorage::IsolatedImageAnalyzer
68
+ elsif VitalsImage.replace_active_storage_analyzer
69
+ app.config.active_storage.analyzers.delete ActiveStorage::Analyzer::ImageAnalyzer
70
+ app.config.active_storage.analyzers.prepend CoreExtensions::ActiveStorage::ImageAnalyzer
71
+ end
72
+ end
73
+ end
74
+
75
+ initializer "vitals_image.action_controller" do
76
+ ActiveSupport.on_load :action_controller do
77
+ helper VitalsImage::TagHelper
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ # Generic base class for all VitalsImage exceptions.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when VitalsImage is given a source it cannot optimize
8
+ class UnoptimizableError < Error; end
9
+
10
+ # Raised when VitalsImage is given a source it cannot extract metadata from
11
+ class UnanalyzableError < Error; end
12
+
13
+ # Raised when VitalsImage is given a url that it cannot download a file from
14
+ class FileNotFoundError < Error; end
15
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ # Returns the version of the currently loaded Active Storage as a <tt>Gem::Version</tt>.
5
+ def self.gem_version
6
+ Gem::Version.new VERSION::STRING
7
+ end
8
+
9
+ module VERSION
10
+ MAJOR = 0
11
+ MINOR = 1
12
+ TINY = 0
13
+ PRE = nil
14
+
15
+ STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
16
+ end
17
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ # This is an abstract base class for optimizers
5
+ class Optimizer
6
+ # Implement this method in a concrete subclass. Have it return true when given a source from which
7
+ # it can calculate dimensions
8
+ def self.accept?(source)
9
+ false
10
+ end
11
+
12
+ def initialize(source, options = {})
13
+ @source = source
14
+ @options = options.deep_stringify_keys
15
+
16
+ raise ArgumentError, "You must specify an alt for your image" if @options["alt"].blank? && VitalsImage.require_alt_attribute
17
+ end
18
+
19
+ def src
20
+ if non_native_lazy_load?
21
+ VitalsImage.lazy_loading_placeholder
22
+ else
23
+ source_url
24
+ end
25
+ end
26
+
27
+ def html_options
28
+ @html_options ||= begin
29
+ html_options = @options.dup
30
+ html_options["width"] = width
31
+ html_options["height"] = height
32
+ html_options["style"] = style
33
+ html_options["class"] = "vitals-image #{html_options["class"]}".squish
34
+
35
+ if non_native_lazy_load?
36
+ html_options["class"] = "#{VitalsImage.lazy_loading} #{html_options["class"]}".squish
37
+ html_options["data"] ||= {}
38
+ html_options["data"]["src"] = source_url
39
+ elsif lazy_load?
40
+ html_options["loading"] = "lazy"
41
+ html_options["decoding"] = "async"
42
+ end
43
+
44
+ html_options.compact
45
+ end
46
+ end
47
+
48
+ def lazy_load?
49
+ @options["lazy_load"] != false && VitalsImage.lazy_loading
50
+ end
51
+
52
+ def non_native_lazy_load?
53
+ lazy_load? && VitalsImage.lazy_loading != :native
54
+ end
55
+
56
+ def native_lazy_load?
57
+ lazy_load? && VitalsImage.lazy_loading == :native
58
+ end
59
+
60
+ # Override this method in a concrete subclass. Have it return true the source is an active storage blob
61
+ def variable?
62
+ false
63
+ end
64
+
65
+ private
66
+ def style
67
+ if analyzed? && !requested_height
68
+ "height:auto;"
69
+ end
70
+ end
71
+
72
+ def width
73
+ if !analyzed? || fixed_dimensions?
74
+ requested_width
75
+ else
76
+ (original_width * scale).round
77
+ end
78
+ end
79
+
80
+ def height
81
+ if !analyzed? || fixed_dimensions?
82
+ requested_height
83
+ else
84
+ (original_height * scale).round
85
+ end
86
+ end
87
+
88
+
89
+ def scale
90
+ [scale_x, scale_y].min
91
+ end
92
+
93
+ def scale_x
94
+ requested_width ? requested_width / original_width : 1.0
95
+ end
96
+
97
+ def scale_y
98
+ requested_height ? requested_height / original_height : 1.0
99
+ end
100
+
101
+ def fixed_dimensions?
102
+ requested_width && requested_height
103
+ end
104
+
105
+ def requested_width
106
+ @options["width"].to_f if @options["width"]
107
+ end
108
+
109
+ def requested_height
110
+ @options["height"].to_f if @options["height"]
111
+ end
112
+
113
+
114
+ # Override this method in a concrete subclass. Have it return true if width and height are available
115
+ def analyzed?
116
+ raise NotImplementedError
117
+ end
118
+
119
+ # Override this method in a concrete subclass. Have it return the width of the image
120
+ def original_width
121
+ raise NotImplementedError
122
+ end
123
+
124
+ # Override this method in a concrete subclass. Have it return the height of the image
125
+ def original_height
126
+ raise NotImplementedError
127
+ end
128
+
129
+
130
+ # Override this method in a concrete subclass. Have it return the url of the image
131
+ def source_url
132
+ raise NotImplementedError
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ class Optimizer::ActiveStorage < Optimizer
5
+ def self.accept?(source)
6
+ source.is_a?(::ActiveStorage::Attached) || source.is_a?(::ActiveStorage::Attachment) || source.is_a?(::ActiveStorage::Blob)
7
+ end
8
+
9
+ def variable?
10
+ true
11
+ end
12
+
13
+ private
14
+ def source_url
15
+ if analyzed?
16
+ variant
17
+ else
18
+ @source
19
+ end
20
+ end
21
+
22
+ def original_width
23
+ metadata[:width]
24
+ end
25
+
26
+ def original_height
27
+ metadata[:height]
28
+ end
29
+
30
+ def metadata
31
+ @source.metadata
32
+ end
33
+
34
+ def analyzed?
35
+ metadata[:analyzed]
36
+ end
37
+
38
+ def alpha?
39
+ @options["alpha"]
40
+ end
41
+
42
+ def dimensions
43
+ if fixed_dimensions?
44
+ [(requested_width * VitalsImage.resolution).floor, (requested_height * VitalsImage.resolution).floor]
45
+ elsif VitalsImage.resolution * scale > 1
46
+ [original_width, original_height]
47
+ else
48
+ [(width * VitalsImage.resolution).floor, (height * VitalsImage.resolution).floor]
49
+ end
50
+ end
51
+
52
+ def resize_mode
53
+ @options[:resize_mode] || @source.metadata["isolated"] ? :resize_and_pad : :resize_to_fill
54
+ end
55
+
56
+ def variant
57
+ case (@source.content_type)
58
+ when /jpg|jpeg/
59
+ optimize_jpeg
60
+ when /png/
61
+ optimize_png
62
+ else
63
+ optimize_generic
64
+ end
65
+ end
66
+
67
+ def optimize_jpeg
68
+ @source.variant VitalsImage.jpeg_optimization.merge("#{resize_mode}": dimensions)
69
+ end
70
+
71
+ def optimize_png
72
+ if alpha? || !VitalsImage.convert_to_jpeg
73
+ @source.variant VitalsImage.png_optimization.merge("#{resize_mode}": dimensions)
74
+ else
75
+ @source.variant VitalsImage.jpeg_conversion.merge("#{resize_mode}": dimensions)
76
+ end
77
+ end
78
+
79
+ def optimize_generic
80
+ if alpha? || !VitalsImage.convert_to_jpeg
81
+ @source.variant("#{resize_mode}": dimensions)
82
+ else
83
+ @source.variant VitalsImage.jpeg_conversion.merge("#{resize_mode}": dimensions)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ class Optimizer::Blank < Optimizer
5
+ def self.accept?(source)
6
+ source.blank?
7
+ end
8
+
9
+ private
10
+ def source_url
11
+ VitalsImage.lazy_loading_placeholder
12
+ end
13
+
14
+ def width
15
+ @options["width"]
16
+ end
17
+
18
+ def height
19
+ @options["height"]
20
+ end
21
+
22
+ def style
23
+ nil
24
+ end
25
+
26
+ def lazy_load?
27
+ false
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VitalsImage
4
+ class Optimizer::Url < Optimizer
5
+ def self.accept?(source)
6
+ uri = URI.parse(source)
7
+ %w( http https ).include?(uri.scheme)
8
+ rescue URI::BadURIError
9
+ false
10
+ rescue URI::InvalidURIError
11
+ false
12
+ end
13
+
14
+ private
15
+ def source_url
16
+ @source
17
+ end
18
+
19
+ def style
20
+ if !analyzed?
21
+ # Do nothing
22
+ elsif !requested_height
23
+ "height:auto;"
24
+ elsif fixed_dimensions?
25
+ "object-fit: contain;"
26
+ end
27
+ end
28
+
29
+ def analyzed?
30
+ metadata.analyzed
31
+ end
32
+
33
+ def original_width
34
+ metadata.width
35
+ end
36
+
37
+ def original_height
38
+ metadata.height
39
+ end
40
+
41
+ def metadata
42
+ Cache.instance.locate(@source)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/test_case"
4
+
5
+ module VitalsImage
6
+ class TestCase < ActiveSupport::TestCase
7
+ include ViewComponent::TestHelpers
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gem_version"
4
+
5
+ module VitalsImage
6
+ # Returns the version of the currently loaded ActiveStorage as a <tt>Gem::Version</tt>
7
+ def self.version
8
+ gem_version
9
+ end
10
+ end
metadata ADDED
@@ -0,0 +1,273 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: vitals_image
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Breno Gazzola
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-05-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activejob
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '6.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '6.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activemodel
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '6.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '6.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activerecord
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '6.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '6.1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: activestorage
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '6.1'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '6.1'
69
+ - !ruby/object:Gem::Dependency
70
+ name: activesupport
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '6.1'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '6.1'
83
+ - !ruby/object:Gem::Dependency
84
+ name: mini_magick
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '4.1'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '4.1'
97
+ - !ruby/object:Gem::Dependency
98
+ name: ruby-vips
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '2.0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '2.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: platform_agent
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.0'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '1.0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: sqlite3
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '1.4'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '1.4'
139
+ - !ruby/object:Gem::Dependency
140
+ name: byebug
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '11.1'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: '11.1'
153
+ - !ruby/object:Gem::Dependency
154
+ name: rubocop
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - "~>"
158
+ - !ruby/object:Gem::Version
159
+ version: '1.14'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - "~>"
165
+ - !ruby/object:Gem::Version
166
+ version: '1.14'
167
+ - !ruby/object:Gem::Dependency
168
+ name: rubocop-performance
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - "~>"
172
+ - !ruby/object:Gem::Version
173
+ version: '1.11'
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - "~>"
179
+ - !ruby/object:Gem::Version
180
+ version: '1.11'
181
+ - !ruby/object:Gem::Dependency
182
+ name: rubocop-packaging
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - "~>"
186
+ - !ruby/object:Gem::Version
187
+ version: '0.5'
188
+ type: :development
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - "~>"
193
+ - !ruby/object:Gem::Version
194
+ version: '0.5'
195
+ - !ruby/object:Gem::Dependency
196
+ name: rubocop-rails
197
+ requirement: !ruby/object:Gem::Requirement
198
+ requirements:
199
+ - - "~>"
200
+ - !ruby/object:Gem::Version
201
+ version: '2.10'
202
+ type: :development
203
+ prerelease: false
204
+ version_requirements: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - "~>"
207
+ - !ruby/object:Gem::Version
208
+ version: '2.10'
209
+ description:
210
+ email:
211
+ - breno@festalab.com
212
+ executables: []
213
+ extensions: []
214
+ extra_rdoc_files: []
215
+ files:
216
+ - MIT-LICENSE
217
+ - Rakefile
218
+ - app/assets/config/vitals_image_manifest.js
219
+ - app/assets/stylesheets/vitals_image/application.css
220
+ - app/controllers/vitals_image/application_controller.rb
221
+ - app/helpers/vitals_image/application_helper.rb
222
+ - app/helpers/vitals_image/tag_helper.rb
223
+ - app/jobs/vitals_image/analyze_job.rb
224
+ - app/jobs/vitals_image/application_job.rb
225
+ - app/models/vitals_image/application_record.rb
226
+ - app/models/vitals_image/source.rb
227
+ - app/views/layouts/vitals_image/application.html.erb
228
+ - config/routes.rb
229
+ - db/migrate/20210502132155_create_vitals_image_sources.rb
230
+ - lib/tasks/vitals_image_tasks.rake
231
+ - lib/vitals_image.rb
232
+ - lib/vitals_image/analyzer.rb
233
+ - lib/vitals_image/analyzer/url.rb
234
+ - lib/vitals_image/base.rb
235
+ - lib/vitals_image/cache.rb
236
+ - lib/vitals_image/core_extensions/active_storage/image_analyzer.rb
237
+ - lib/vitals_image/core_extensions/active_storage/isolated_image_analyzer.rb
238
+ - lib/vitals_image/engine.rb
239
+ - lib/vitals_image/errors.rb
240
+ - lib/vitals_image/gem_version.rb
241
+ - lib/vitals_image/optimizer.rb
242
+ - lib/vitals_image/optimizer/active_storage.rb
243
+ - lib/vitals_image/optimizer/blank.rb
244
+ - lib/vitals_image/optimizer/url.rb
245
+ - lib/vitals_image/test_case.rb
246
+ - lib/vitals_image/version.rb
247
+ homepage: https://github.com/FestaLab/vitals_image
248
+ licenses:
249
+ - MIT
250
+ metadata:
251
+ homepage_uri: https://github.com/FestaLab/vitals_image
252
+ source_code_uri: https://github.com/FestaLab/vitals_image
253
+ changelog_uri: https://github.com/FestaLab/vitals_image/CHANGELOG.mg
254
+ post_install_message:
255
+ rdoc_options: []
256
+ require_paths:
257
+ - lib
258
+ required_ruby_version: !ruby/object:Gem::Requirement
259
+ requirements:
260
+ - - ">="
261
+ - !ruby/object:Gem::Version
262
+ version: 2.5.0
263
+ required_rubygems_version: !ruby/object:Gem::Requirement
264
+ requirements:
265
+ - - ">="
266
+ - !ruby/object:Gem::Version
267
+ version: '0'
268
+ requirements: []
269
+ rubygems_version: 3.2.17
270
+ signing_key:
271
+ specification_version: 4
272
+ summary: Image tags that conform with web vitals
273
+ test_files: []