diff --git a/lib/imgproxy-rails/railtie.rb b/lib/imgproxy-rails/railtie.rb index c718eb0..13dd147 100644 --- a/lib/imgproxy-rails/railtie.rb +++ b/lib/imgproxy-rails/railtie.rb @@ -4,38 +4,26 @@ require "imgproxy-rails/transformer" module ImgproxyRails - module UrlHelpers + class Railtie < ::Rails::Railtie VARIANT_CLASSES = [ "ActiveStorage::Variant", "ActiveStorage::VariantWithRecord" ].freeze - def imgproxy_active_storage_url(*args) - record = args[0] - - if VARIANT_CLASSES.any? { |klass| record.is_a?(klass.constantize) } - imgproxy_url(record) - else - rails_storage_proxy_url(*args) + initializer "imgproxy-rails.routes" do + config.after_initialize do |app| + app.routes.prepend do + direct :imgproxy_active_storage do |model, options| + if VARIANT_CLASSES.any? { |klass| model.is_a?(klass.constantize) } + url = route_for(:rails_storage_proxy, model.blob, options) + transformations = model.variation.transformations + Imgproxy.url_for(url, Transformer.call(transformations, model.blob.metadata)) + else + route_for(:rails_storage_proxy, model, options) + end + end + end end end - - def imgproxy_active_storage_path(*args) - rails_storage_proxy_path(*args) - end - - private - - def imgproxy_url(record) - transformations = record.variation.transformations - url = rails_storage_proxy_url(record.blob) - ::Imgproxy.url_for(url, Transformer.call(transformations)) - end - end - - class RailtieHelpers < ::Rails::Railtie - initializer "imgproxy-rails.include_url_helpers" do - ActionDispatch::Routing::UrlFor.include UrlHelpers - end end end diff --git a/lib/imgproxy-rails/transformer.rb b/lib/imgproxy-rails/transformer.rb index 4297f5e..0c97b05 100644 --- a/lib/imgproxy-rails/transformer.rb +++ b/lib/imgproxy-rails/transformer.rb @@ -2,41 +2,96 @@ module ImgproxyRails class Transformer + MAP = { + resize: proc do |p| + width, height = p.split("x") + {width: width, height: height} + end, + resize_to_limit: proc { |p| {width: p[0], height: p[1]} }, + resize_to_fit: proc { |p| {width: p[0], height: p[1]} }, + resize_to_fill: proc { |p| {width: p[0], height: p[1], resizing_type: :fill} }, + resize_and_pad: proc { |p, m| resize_and_pad(p, m) }, + crop: proc { false }, # Cropping API is different in imgproxy + monochrome: proc { false }, + rotate: proc { true }, + convert: proc { |p| {format: p} }, + sharpen: proc { true }, # TODO: needs to be weighted + blur: proc { true }, # TODO: needs to be weighted + quality: proc { true }, # TODO: needs to be weighted + trim: proc { {trim: 0} }, + modulate: proc { |p| modulate(p) } + } + class << self - MAP = { - resize: ->(p) do - width, height = p.split("x") - {width: width, height: height} - end, - resize_to_limit: ->(p) { {width: p[0], height: p[1], type: :fit} }, - resize_to_fit: ->(p) { {width: p[0], height: p[1], type: :fit} }, - resize_to_fill: ->(p) { {width: p[0], height: p[1], type: :fill} }, - resize_and_pad: ->(p) { false }, - crop: ->(p) { false }, - rotate: ->(p) { p }, - convert: ->(p) { {format: p} }, - define: ->(p) { false }, - monochrome: ->(p) { false }, - flip: ->(p) { false }, - sharpen: ->(p) { p } - } - - def call(transformations) + def call(transformations, meta) passed_options = transformations.delete(:imgproxy_options) || {} - mapped_options = transformations.each_with_object({}) do |(key, value), memo| - next unless MAP.key?(key) - - value = MAP[key].call(value) - next if value.is_a?(FalseClass) + mapped_options = transformations.each_with_object({}) do |(t_key, t_value), memo| + next unless MAP.key?(t_key) - if value.is_a?(Hash) - memo.merge!(value) + map_value = MAP[t_key].call(t_value, meta) + case map_value + when FalseClass + next # TODO: log warning? + when TrueClass + memo[t_key] = t_value + when Hash + memo.merge!(map_value) else - memo[key] = value + raise "Unexpected map value class" end end mapped_options.merge(passed_options) end + + private + + def convert_color(color) + return unless color || color =~ /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/ + + color.sub(/^#/, "") + end + + def resize_and_pad(p, m) + target_width, target_height, options = p + + result = {width: target_width, height: target_height} + return result unless m["width"] && m["height"] + + aspect_ratio = m["width"].to_f / m["height"] + if aspect_ratio > 1 + # add vertical padding + final_height = target_width.to_f / aspect_ratio + padding_length = ((target_height - final_height) / 2).round + result[:padding] = [padding_length, 0] + + # setting min-width for correct upscaling + result[:mw] = target_width + else + # add horizontal padding + final_width = target_height.to_f * aspect_ratio + padding_length = ((target_width - final_width) / 2).round + result[:padding] = [0, padding_length] + + # setting min-height for correct upscaling + result[:mh] = target_height + end + + if (background = convert_color(options[:background])) + result[:background] = background + end + + result + end + + def modulate(p) + brightness, saturation, _ = p.split(",").map(&:to_i) + + result = {} + result[:brightness] = brightness if brightness + result[:saturation] = saturation if saturation + + result + end end end end diff --git a/spec/internal_spec.rb b/spec/internal_spec.rb index 02896fb..c8b4bab 100644 --- a/spec/internal_spec.rb +++ b/spec/internal_spec.rb @@ -1,8 +1,6 @@ require "rails_helper" RSpec.describe "Dummy app", type: :request do - include ActionView::Helpers::AssetTagHelper - let(:option) { record } let(:record) { user.avatar.variant(resize_to_limit: [500, 500]) } let(:user) do @@ -15,9 +13,14 @@ end end - before { Rails.application.routes.default_url_options[:host] = "http://example.com" } + before do + Rails.application.routes.default_url_options[:host] = "http://example.com" + Imgproxy.configure { |config| config.endpoint = "http://imgproxy.io" } + end describe "image_tag" do + include ActionView::Helpers::AssetTagHelper + subject { image_tag(option) } shared_context "handles string correctly" do @@ -34,12 +37,7 @@ end describe "with resolve_model_to_route = :imgproxy_active_storage" do - before do - ActiveStorage.resolve_model_to_route = :imgproxy_active_storage - Imgproxy.configure do |config| - config.endpoint = "http://imgproxy.io" - end - end + before { ActiveStorage.resolve_model_to_route = :imgproxy_active_storage } it { is_expected.to include(' 1664, "height" => 960} } + + it_behaves_like "transforms to", + width: 1000, + height: 1000, + padding: [212, 0], + background: "bbbbc4", + mw: 1000 + + context "target height and width > original image" do + let(:params) { [2000, 2000, {background: "#bbbbc4"}] } + let(:meta) { {"width" => 1664, "height" => 960} } + + it_behaves_like "transforms to", + width: 2000, + height: 2000, + padding: [423, 0], + background: "bbbbc4", + mw: 2000 + end + + context "target height and width < original image" do + let(:params) { [500, 500, {background: "#bbbbc4"}] } + let(:meta) { {"width" => 1664, "height" => 960} } + + it_behaves_like "transforms to", + width: 500, + height: 500, + padding: [106, 0], + background: "bbbbc4", + mw: 500 + end + end + + context "vertical" do + let(:params) { [1000, 1000, {background: "#bbbbc4"}] } + let(:meta) { {"width" => 799, "height" => 1280} } + + it_behaves_like "transforms to", + width: 1000, + height: 1000, + padding: [0, 188], + background: "bbbbc4", + mh: 1000 + + context "target height and width > original image" do + let(:params) { [2000, 2000, {background: "#bbbbc4"}] } + let(:meta) { {"width" => 799, "height" => 1280} } + + it_behaves_like "transforms to", + width: 2000, + height: 2000, + padding: [0, 376], + background: "bbbbc4", + mh: 2000 + end + + context "target height and width < original image" do + let(:params) { [500, 500, {background: "#bbbbc4"}] } + let(:meta) { {"width" => 799, "height" => 1280} } + + it_behaves_like "transforms to", + width: 500, + height: 500, + padding: [0, 94], + background: "bbbbc4", + mh: 500 + end + end + end end