vitals_image 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []