dynamic_image 3.0.8 → 3.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.
- checksums.yaml +4 -4
- data/README.md +393 -67
- data/Rakefile +3 -0
- data/app/models/dynamic_image/variant.rb +10 -0
- data/lib/dynamic_image/backfill.rb +85 -0
- data/lib/dynamic_image/belongs_to.rb +22 -0
- data/lib/dynamic_image/breakpoints.rb +93 -0
- data/lib/dynamic_image/controller.rb +57 -12
- data/lib/dynamic_image/digest_verifier.rb +20 -9
- data/lib/dynamic_image/engine.rb +4 -1
- data/lib/dynamic_image/errors.rb +30 -0
- data/lib/dynamic_image/format.rb +149 -8
- data/lib/dynamic_image/format_negotiator.rb +84 -0
- data/lib/dynamic_image/helper/formats.rb +57 -0
- data/lib/dynamic_image/helper/pictures.rb +124 -0
- data/lib/dynamic_image/helper.rb +100 -60
- data/lib/dynamic_image/image_processor/colors.rb +3 -7
- data/lib/dynamic_image/image_processor/frames.rb +10 -1
- data/lib/dynamic_image/image_processor/transform.rb +17 -2
- data/lib/dynamic_image/image_processor.rb +33 -6
- data/lib/dynamic_image/image_reader.rb +19 -1
- data/lib/dynamic_image/image_sizing.rb +51 -19
- data/lib/dynamic_image/metadata.rb +56 -5
- data/lib/dynamic_image/model/dimensions.rb +32 -13
- data/lib/dynamic_image/model/transformations.rb +50 -12
- data/lib/dynamic_image/model/validations.rb +15 -8
- data/lib/dynamic_image/model/variants.rb +6 -3
- data/lib/dynamic_image/model.rb +64 -35
- data/lib/dynamic_image/picture/format_policy.rb +72 -0
- data/lib/dynamic_image/picture.rb +215 -0
- data/lib/dynamic_image/processed_image.rb +53 -17
- data/lib/dynamic_image/ratio.rb +43 -0
- data/lib/dynamic_image/routing.rb +14 -3
- data/lib/dynamic_image/schema.rb +47 -0
- data/lib/dynamic_image/version.rb +1 -1
- data/lib/dynamic_image.rb +61 -1
- data/lib/rails/generators/dynamic_image/resource/USAGE +17 -0
- data/lib/rails/generators/dynamic_image/resource/resource_generator.rb +57 -21
- data/lib/rails/generators/dynamic_image/resource/templates/create_table_migration.rb.tt +32 -0
- data/lib/rails/generators/dynamic_image/upgrade/USAGE +17 -0
- data/lib/rails/generators/dynamic_image/upgrade/templates/upgrade_migration.rb.tt +10 -0
- data/lib/rails/generators/dynamic_image/upgrade/upgrade_generator.rb +111 -0
- data/lib/tasks/dynamic_image.rake +25 -0
- metadata +26 -15
- data/lib/dynamic_image/jobs/create_variant.rb +0 -22
- data/lib/dynamic_image/jobs.rb +0 -3
data/lib/dynamic_image/model.rb
CHANGED
|
@@ -8,8 +8,8 @@ require "dynamic_image/model/variants"
|
|
|
8
8
|
module DynamicImage
|
|
9
9
|
# = DynamicImage Model
|
|
10
10
|
#
|
|
11
|
-
# ActiveModel extension for the model holding image data.
|
|
12
|
-
#
|
|
11
|
+
# ActiveModel extension for the model holding image data. The table needs at least the attributes in
|
|
12
|
+
# {DynamicImage::Schema::ATTRIBUTES}:
|
|
13
13
|
#
|
|
14
14
|
# create_table :images do |t|
|
|
15
15
|
# t.string :content_hash
|
|
@@ -24,7 +24,7 @@ module DynamicImage
|
|
|
24
24
|
# t.timestamps
|
|
25
25
|
# end
|
|
26
26
|
#
|
|
27
|
-
#
|
|
27
|
+
# Include it in your model:
|
|
28
28
|
#
|
|
29
29
|
# class Image < ActiveRecord::Base
|
|
30
30
|
# include DynamicImage::Model
|
|
@@ -32,23 +32,18 @@ module DynamicImage
|
|
|
32
32
|
#
|
|
33
33
|
# == Usage
|
|
34
34
|
#
|
|
35
|
-
# To save an image,
|
|
35
|
+
# To save an image, assign to the +file+ attribute. The image is parsed and validated when the record is saved.
|
|
36
36
|
#
|
|
37
37
|
# image = Image.create(file: params.permit(:file))
|
|
38
38
|
#
|
|
39
|
-
#
|
|
40
|
-
# saved.
|
|
41
|
-
#
|
|
42
|
-
# To read back the image data, access the +data+ attribute. This will lazily
|
|
43
|
-
# load the data from the store.
|
|
39
|
+
# To read back the image data, access the +data+ attribute. The data is loaded lazily from the store.
|
|
44
40
|
#
|
|
45
41
|
# data = image.data
|
|
46
42
|
#
|
|
47
43
|
# == Cropping
|
|
48
44
|
#
|
|
49
|
-
# Images can be pre-cropped by setting +crop_width+, +crop_height+,
|
|
50
|
-
#
|
|
51
|
-
# image size.
|
|
45
|
+
# Images can be pre-cropped by setting +crop_width+, +crop_height+, +crop_start_x+ and +crop_start_y+. The crop
|
|
46
|
+
# dimensions cannot exceed the image size.
|
|
52
47
|
#
|
|
53
48
|
# image.update(
|
|
54
49
|
# crop_start_x: 15, crop_start_y: 20,
|
|
@@ -56,10 +51,14 @@ module DynamicImage
|
|
|
56
51
|
# )
|
|
57
52
|
# image.size # => Vector2d(300, 200)
|
|
58
53
|
#
|
|
59
|
-
# By default, images will be cropped from the center. You can control this
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
54
|
+
# By default, images will be cropped from the center. You can control this by setting +crop_gravity_x+ and
|
|
55
|
+
# +crop_gravity_y+. DynamicImage will make sure the pixel referred to by these coordinates are present in the
|
|
56
|
+
# cropped image, and as close to the center as possible without zooming in.
|
|
57
|
+
#
|
|
58
|
+
# @see DynamicImage::Model::Dimensions
|
|
59
|
+
# @see DynamicImage::Model::Transformations
|
|
60
|
+
# @see DynamicImage::Model::Validations
|
|
61
|
+
# @see DynamicImage::Model::Variants
|
|
63
62
|
module Model
|
|
64
63
|
extend ActiveSupport::Concern
|
|
65
64
|
include Dis::Model
|
|
@@ -72,33 +71,62 @@ module DynamicImage
|
|
|
72
71
|
before_validation :read_image_metadata, if: :data_changed?
|
|
73
72
|
end
|
|
74
73
|
|
|
75
|
-
# Returns true if the image
|
|
74
|
+
# Returns true if the image holds more than one frame.
|
|
75
|
+
#
|
|
76
|
+
# Images stored before +frame_count+ existed have none, and are taken to be still.
|
|
77
|
+
#
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
def animated?
|
|
80
|
+
has_attribute?(:frame_count) && frame_count.to_i > 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns the alt text for the image, or nil if none has been set.
|
|
84
|
+
#
|
|
85
|
+
# DynamicImage doesn't add this column by default. Either create it yourself or override the method to
|
|
86
|
+
# provide your own implementation.
|
|
87
|
+
#
|
|
88
|
+
# Note that there is a distinction between nil and a blank string. <tt>alt=""</tt> means the image is
|
|
89
|
+
# purely decorative, while a missing attribute is an accessibility defect.
|
|
90
|
+
#
|
|
91
|
+
# @return [String, nil]
|
|
92
|
+
# @see DynamicImage::Helper#dynamic_image_tag
|
|
93
|
+
def alt_text
|
|
94
|
+
self[:alt_text] if has_attribute?(:alt_text)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Returns true if the image is in the CMYK colorspace.
|
|
98
|
+
#
|
|
99
|
+
# @return [Boolean]
|
|
76
100
|
def cmyk?
|
|
77
101
|
colorspace == "cmyk"
|
|
78
102
|
end
|
|
79
103
|
|
|
80
|
-
# Returns true if the image is in the grayscale colorspace
|
|
104
|
+
# Returns true if the image is in the grayscale colorspace.
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean]
|
|
81
107
|
def gray?
|
|
82
108
|
colorspace == "gray"
|
|
83
109
|
end
|
|
84
110
|
|
|
85
|
-
# Returns true if the image is in the RGB colorspace
|
|
111
|
+
# Returns true if the image is in the RGB colorspace.
|
|
112
|
+
#
|
|
113
|
+
# @return [Boolean]
|
|
86
114
|
def rgb?
|
|
87
115
|
colorspace == "rgb"
|
|
88
116
|
end
|
|
89
117
|
|
|
90
|
-
# Finds a web safe content type
|
|
91
|
-
#
|
|
118
|
+
# Finds a web safe content type, negotiated against {DynamicImage.default_formats}.
|
|
119
|
+
#
|
|
120
|
+
# @return [String]
|
|
121
|
+
# @see DynamicImage::FormatNegotiator
|
|
92
122
|
def safe_content_type
|
|
93
|
-
|
|
94
|
-
content_type
|
|
95
|
-
else
|
|
96
|
-
"image/jpeg"
|
|
97
|
-
end
|
|
123
|
+
DynamicImage::FormatNegotiator
|
|
124
|
+
.new(self).negotiate(DynamicImage.default_formats).content_type
|
|
98
125
|
end
|
|
99
126
|
|
|
100
|
-
# Includes a timestamp fingerprint in the URL param, so
|
|
101
|
-
#
|
|
127
|
+
# Includes a timestamp fingerprint in the URL param, so rendered images can be cached indefinitely.
|
|
128
|
+
#
|
|
129
|
+
# @return [String] the id and an +updated_at+ fingerprint
|
|
102
130
|
def to_param
|
|
103
131
|
[id, updated_at.utc.to_fs(cache_timestamp_format)].join("-")
|
|
104
132
|
end
|
|
@@ -106,25 +134,26 @@ module DynamicImage
|
|
|
106
134
|
private
|
|
107
135
|
|
|
108
136
|
def read_image_metadata
|
|
109
|
-
metadata = DynamicImage::Metadata.new(Pathname(data_file_path))
|
|
110
137
|
@valid_image = false
|
|
138
|
+
with_data_file do |path|
|
|
139
|
+
apply_image_metadata(DynamicImage::Metadata.new(path))
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def apply_image_metadata(metadata)
|
|
111
144
|
return unless metadata.valid?
|
|
112
145
|
|
|
113
146
|
self.colorspace = metadata.colorspace
|
|
114
147
|
self.real_width = metadata.width
|
|
115
148
|
self.real_height = metadata.height
|
|
116
149
|
self.content_type = metadata.content_type
|
|
150
|
+
self.frame_count = metadata.frame_count if has_attribute?(:frame_count)
|
|
151
|
+
self.alpha = metadata.alpha? if has_attribute?(:alpha)
|
|
117
152
|
@valid_image = true
|
|
118
153
|
end
|
|
119
154
|
|
|
120
155
|
def valid_image?
|
|
121
156
|
@valid_image ? true : false
|
|
122
157
|
end
|
|
123
|
-
|
|
124
|
-
def safe_content_types
|
|
125
|
-
%w[image/png
|
|
126
|
-
image/gif
|
|
127
|
-
image/jpeg]
|
|
128
|
-
end
|
|
129
158
|
end
|
|
130
159
|
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DynamicImage
|
|
4
|
+
class Picture
|
|
5
|
+
# = DynamicImage Picture Format Policy
|
|
6
|
+
#
|
|
7
|
+
# Decides what a responsive image is rendered in. The <tt>source</tt> gets the best format available for
|
|
8
|
+
# output, transcoded unconditionally. The <tt>img</tt> gets one of {DynamicImage::COMPATIBLE_FORMATS}.
|
|
9
|
+
#
|
|
10
|
+
# @see DynamicImage::FormatNegotiator
|
|
11
|
+
class FormatPolicy
|
|
12
|
+
# The format the candidates are rendered in when the view doesn't ask for one.
|
|
13
|
+
DEFAULT_SOURCE_FORMAT = :webp
|
|
14
|
+
|
|
15
|
+
# @!attribute [r] record
|
|
16
|
+
# @return [DynamicImage::Model]
|
|
17
|
+
# @!attribute [r] requested
|
|
18
|
+
# @return [Symbol, Array<Symbol>, nil]
|
|
19
|
+
attr_reader :record, :requested
|
|
20
|
+
|
|
21
|
+
# @param record [DynamicImage::Model] the image
|
|
22
|
+
# @param requested [Symbol, Array<Symbol>, nil] the format the candidates are rendered in. A symbol forces
|
|
23
|
+
# that format, an array is negotiated, nil takes the default.
|
|
24
|
+
def initialize(record, requested = nil)
|
|
25
|
+
@record = record
|
|
26
|
+
@requested = requested
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The format the candidates are rendered in.
|
|
30
|
+
#
|
|
31
|
+
# @return [DynamicImage::Format]
|
|
32
|
+
def source
|
|
33
|
+
@source ||= resolve(requested || default_source)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The format the fallback image is rendered in.
|
|
37
|
+
#
|
|
38
|
+
# @return [DynamicImage::Format]
|
|
39
|
+
def fallback
|
|
40
|
+
@fallback ||= resolve(fallback_formats)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def default_source
|
|
46
|
+
record.animated? ? fallback_formats : DEFAULT_SOURCE_FORMAT
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def resolve(value)
|
|
50
|
+
return negotiate(value) if value.is_a?(Array)
|
|
51
|
+
|
|
52
|
+
DynamicImage::Format.find(value) ||
|
|
53
|
+
raise(ArgumentError, "unknown format: #{value.inspect}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def negotiate(formats)
|
|
57
|
+
DynamicImage::FormatNegotiator.new(record).negotiate(formats)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# An animated image keeps its own format if it can.
|
|
61
|
+
def fallback_formats
|
|
62
|
+
return DynamicImage::COMPATIBLE_FORMATS unless record.animated?
|
|
63
|
+
|
|
64
|
+
[stored_format_name, *DynamicImage::COMPATIBLE_FORMATS].compact
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stored_format_name
|
|
68
|
+
DynamicImage::Format.content_type(record.content_type)&.name
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DynamicImage
|
|
4
|
+
# = DynamicImage Picture
|
|
5
|
+
#
|
|
6
|
+
# Everything needed to render an image responsively: the candidate widths, the signed URLs behind them, and the
|
|
7
|
+
# fallback the <tt>img</tt> points at.
|
|
8
|
+
#
|
|
9
|
+
# Sizing is expressed as an optional ratio instead of a size.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# picture = DynamicImage::Picture.new(self, image, ratio: "16:9")
|
|
13
|
+
# picture.srcset # => "/images/… 420w, /images/… 590w, …"
|
|
14
|
+
# picture.src # => "/images/…/1200x675/…jpg"
|
|
15
|
+
#
|
|
16
|
+
# @see DynamicImage::Breakpoints for how the widths are chosen
|
|
17
|
+
# @see DynamicImage::Helper#dynamic_picture_tag
|
|
18
|
+
class Picture
|
|
19
|
+
# The options this class consumes. Anything else is passed on to the router.
|
|
20
|
+
OPTIONS = %i[ratio sizes breakpoints step fallback_width format uncropped].freeze
|
|
21
|
+
|
|
22
|
+
# @!attribute [r] record
|
|
23
|
+
# @return [DynamicImage::Model]
|
|
24
|
+
# @!attribute [r] ratio
|
|
25
|
+
# @return [Float, nil]
|
|
26
|
+
# @!attribute [r] sizes
|
|
27
|
+
# @return [String] the +sizes+ attribute
|
|
28
|
+
# @!attribute [r] breakpoints
|
|
29
|
+
# @return [DynamicImage::Breakpoints] the candidate widths
|
|
30
|
+
# @!attribute [r] fallback_width
|
|
31
|
+
# @return [Integer] the width asked for the fallback image
|
|
32
|
+
attr_reader :template, :record_or_array, :ratio, :sizes, :breakpoints, :fallback_width, :url_options
|
|
33
|
+
|
|
34
|
+
# @param template [ActionView::Base] the view context, for routing
|
|
35
|
+
# @param record_or_array [DynamicImage::Model, Array] the record, or an array of records for a nested route
|
|
36
|
+
# @param options [Hash]
|
|
37
|
+
# @option options [Numeric, Vector2d, String, nil] :ratio The aspect ratio to crop to, as a number, a vector, or a
|
|
38
|
+
# string like <tt>"16:9"</tt>. Implies cropping. Omit for the image's own.
|
|
39
|
+
# @option options [String] :sizes The +sizes+ attribute
|
|
40
|
+
# @option options [Range, Array<Integer>, Integer] :breakpoints The widths to offer, overriding
|
|
41
|
+
# {DynamicImage.default_breakpoints}
|
|
42
|
+
# @option options [Numeric] :step The step between breakpoints, overriding {DynamicImage.breakpoint_step}
|
|
43
|
+
# @option options [Integer] :fallback_width The width to ask for the fallback image, overriding
|
|
44
|
+
# {DynamicImage.picture_fallback_width}
|
|
45
|
+
# @option options [Symbol, Array<Symbol>] :format The format the candidates are rendered in. A symbol forces
|
|
46
|
+
# that format, an array is negotiated. Defaults to WebP, or the image's own format if it is animated.
|
|
47
|
+
# @option options [Boolean] :uncropped Size against the whole image, ignoring any pre-cropping. Defaults to
|
|
48
|
+
# whether the URL points at the +uncropped+ action.
|
|
49
|
+
#
|
|
50
|
+
# Any options supported by +polymorphic_url+ are also accepted, and passed on to the router.
|
|
51
|
+
def initialize(template, record_or_array, options = {})
|
|
52
|
+
options = options.symbolize_keys
|
|
53
|
+
@template = template
|
|
54
|
+
@record_or_array = Array(record_or_array)
|
|
55
|
+
@ratio = DynamicImage::Ratio.parse(options[:ratio])
|
|
56
|
+
@sizes = options[:sizes] || "100vw"
|
|
57
|
+
@requested_format = options[:format]
|
|
58
|
+
@fallback_width = fallback_width_from(options)
|
|
59
|
+
@breakpoints = breakpoints_from(options)
|
|
60
|
+
@url_options = options.except(*OPTIONS)
|
|
61
|
+
@uncropped = options.fetch(:uncropped) { url_options[:action].to_s == "uncropped" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns true if the image is cropped, which it is whenever a ratio is given.
|
|
65
|
+
#
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def crop?
|
|
68
|
+
!ratio.nil?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The widest the image can be rendered at: its own width, or the width of the largest crop matching the ratio.
|
|
72
|
+
#
|
|
73
|
+
# @return [Integer]
|
|
74
|
+
def available_width
|
|
75
|
+
@available_width ||= sizing.available_width(ratio)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The candidate widths, smallest first.
|
|
79
|
+
#
|
|
80
|
+
# @return [Array<Integer>]
|
|
81
|
+
def widths
|
|
82
|
+
@widths ||= breakpoints.widths(available_width)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Every candidate, as the URL and the size it is actually rendered at.
|
|
86
|
+
#
|
|
87
|
+
# @return [Array<Hash>]
|
|
88
|
+
def variants
|
|
89
|
+
@variants ||= widths.map { |width| variant(width) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The +srcset+ attribute for the candidates, or nil if there are none. It belongs on the <tt>source</tt> when
|
|
93
|
+
# {#sources} has one, and on the <tt>img</tt> when it doesn't.
|
|
94
|
+
#
|
|
95
|
+
# @return [String, nil]
|
|
96
|
+
def srcset
|
|
97
|
+
return if variants.empty?
|
|
98
|
+
|
|
99
|
+
variants.map { |v| "#{v[:url]} #{v[:width]}w" }.join(", ")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The sources to render, as +type+ and +srcset+ pairs.
|
|
103
|
+
#
|
|
104
|
+
# Empty when the candidates are in the same format as the fallback; {#srcset} goes on the <tt>img</tt> instead.
|
|
105
|
+
# That is what happens to an animated image, which keeps its own format.
|
|
106
|
+
#
|
|
107
|
+
# @return [Array<Hash>]
|
|
108
|
+
def sources
|
|
109
|
+
candidates = srcset
|
|
110
|
+
return [] if candidates.nil? || format == fallback_format
|
|
111
|
+
|
|
112
|
+
[{ type:, srcset: candidates }]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The content type the candidates are rendered as, for the +type+ attribute.
|
|
116
|
+
#
|
|
117
|
+
# @return [String]
|
|
118
|
+
def type
|
|
119
|
+
format.content_type
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The format the fallback image is rendered in, negotiated from {DynamicImage::COMPATIBLE_FORMATS}.
|
|
123
|
+
#
|
|
124
|
+
# @return [DynamicImage::Format]
|
|
125
|
+
def fallback_format
|
|
126
|
+
format_policy.fallback
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The size asked for the fallback image, as a <tt>"{width}x{height}"</tt> string.
|
|
130
|
+
#
|
|
131
|
+
# @return [String]
|
|
132
|
+
def fallback_size
|
|
133
|
+
@fallback_size ||= size_for(fallback_width)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The size the fallback image is actually rendered at. Smaller than {#fallback_size} when the image is.
|
|
137
|
+
#
|
|
138
|
+
# @return [Vector2d]
|
|
139
|
+
def dimensions
|
|
140
|
+
@dimensions ||= sizing.fit(fallback_size, crop: crop?).floor
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The URL for the fallback image.
|
|
144
|
+
#
|
|
145
|
+
# @return [String]
|
|
146
|
+
def src
|
|
147
|
+
url_for(fallback_size, fallback_format)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The width of the fallback image.
|
|
151
|
+
#
|
|
152
|
+
# @return [Integer]
|
|
153
|
+
def width
|
|
154
|
+
dimensions.x.to_i
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The height of the fallback image.
|
|
158
|
+
#
|
|
159
|
+
# @return [Integer]
|
|
160
|
+
def height
|
|
161
|
+
dimensions.y.to_i
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def fallback_width_from(options)
|
|
167
|
+
(options[:fallback_width] || DynamicImage.picture_fallback_width).to_i
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def breakpoints_from(options)
|
|
171
|
+
DynamicImage::Breakpoints.new(options[:breakpoints], step: options[:step])
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def record
|
|
175
|
+
record_or_array.last
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def sizing
|
|
179
|
+
@sizing ||= DynamicImage::ImageSizing.new(record, uncropped: @uncropped)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def variant(width)
|
|
183
|
+
requested = size_for(width)
|
|
184
|
+
size = sizing.fit(requested, crop: crop?).floor
|
|
185
|
+
# {#url_for} fits the size it is given. +size+ has been floored to whole pixels, so it is no longer exactly
|
|
186
|
+
# proportional and would fit smaller a second time. Pass the request instead.
|
|
187
|
+
{ url: url_for(requested, format),
|
|
188
|
+
width: size.x.to_i,
|
|
189
|
+
height: size.y.to_i }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def size_for(width)
|
|
193
|
+
return "#{width}x" unless ratio
|
|
194
|
+
|
|
195
|
+
"#{width}x#{(width / ratio).round}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def url_for(requested_size, image_format)
|
|
199
|
+
template.dynamic_image_path(record_or_array,
|
|
200
|
+
url_options.merge(
|
|
201
|
+
size: requested_size,
|
|
202
|
+
crop: crop?,
|
|
203
|
+
format: image_format.mime_type.to_sym
|
|
204
|
+
))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def format
|
|
208
|
+
format_policy.source
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def format_policy
|
|
212
|
+
@format_policy ||= FormatPolicy.new(record, @requested_format)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -3,11 +3,22 @@
|
|
|
3
3
|
module DynamicImage
|
|
4
4
|
# = DynamicImage Processed Image
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
6
|
+
# Crops, resizes and converts images, and reads back the processed data.
|
|
7
|
+
#
|
|
8
|
+
# Each processed size is stored as a variant, so subsequent calls for the same size are cheap.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# size = DynamicImage::ImageSizing.new(image).fit("800x800")
|
|
12
|
+
# data = DynamicImage::ProcessedImage.new(image, format: :jpg)
|
|
13
|
+
# .cropped_and_resized(size)
|
|
8
14
|
class ProcessedImage
|
|
15
|
+
# @return [DynamicImage::Model]
|
|
9
16
|
attr_reader :record
|
|
10
17
|
|
|
18
|
+
# @param record [DynamicImage::Model] the image to process
|
|
19
|
+
# @param options [Hash]
|
|
20
|
+
# @option options [Boolean] :uncropped Ignore any crop stored on the record
|
|
21
|
+
# @option options [Symbol, String] :format Format to convert to. Defaults to the format of the stored image.
|
|
11
22
|
def initialize(record, options = {})
|
|
12
23
|
@record = record
|
|
13
24
|
@uncropped = options[:uncropped] ? true : false
|
|
@@ -15,28 +26,40 @@ module DynamicImage
|
|
|
15
26
|
@format_name = "JPEG" if defined?(@format_name) && @format_name == "JPG"
|
|
16
27
|
end
|
|
17
28
|
|
|
18
|
-
# Crops and
|
|
29
|
+
# Crops, resizes and normalizes the image.
|
|
19
30
|
#
|
|
20
|
-
#
|
|
31
|
+
# @param size [Vector2d] the size to render, in pixels
|
|
32
|
+
# @return [String] the image data as a binary string
|
|
33
|
+
# @raise [DynamicImage::Errors::InvalidImage] if the record isn't a valid image, or if the stored data can't be
|
|
34
|
+
# processed
|
|
21
35
|
#
|
|
36
|
+
# @example
|
|
22
37
|
# processed = DynamicImage::ProcessedImage.new(image)
|
|
23
38
|
# image_data = processed.cropped_and_resized(Vector2d.new(200, 200))
|
|
24
|
-
#
|
|
25
|
-
# Returns a binary string.
|
|
26
39
|
def cropped_and_resized(size)
|
|
27
40
|
return crop_and_resize(size) unless record.persisted?
|
|
28
41
|
|
|
29
42
|
find_or_create_variant(size).data
|
|
30
43
|
end
|
|
31
44
|
|
|
32
|
-
#
|
|
45
|
+
# Returns the variant for the given size, creating it if it doesn't already exist.
|
|
46
|
+
#
|
|
47
|
+
# @param size [Vector2d] the size to render, in pixels
|
|
48
|
+
# @return [DynamicImage::Variant]
|
|
49
|
+
# @raise [DynamicImage::Errors::InvalidImage] if the record isn't a valid image, or if the stored data can't be
|
|
50
|
+
# processed
|
|
33
51
|
def find_or_create_variant(size)
|
|
34
52
|
find_variant(size) || create_variant(size)
|
|
35
53
|
rescue ActiveRecord::RecordNotUnique
|
|
36
54
|
find_variant(size)
|
|
37
55
|
end
|
|
38
56
|
|
|
39
|
-
#
|
|
57
|
+
# Returns the variant for the given size, if one exists.
|
|
58
|
+
#
|
|
59
|
+
# Variants whose data has gone missing from storage are destroyed and treated as absent.
|
|
60
|
+
#
|
|
61
|
+
# @param size [Vector2d] the size to look for, in pixels
|
|
62
|
+
# @return [DynamicImage::Variant, nil] the variant, if one exists
|
|
40
63
|
def find_variant(size)
|
|
41
64
|
return nil unless record.persisted?
|
|
42
65
|
|
|
@@ -51,8 +74,10 @@ module DynamicImage
|
|
|
51
74
|
end
|
|
52
75
|
end
|
|
53
76
|
|
|
54
|
-
#
|
|
55
|
-
#
|
|
77
|
+
# Same as {#cropped_and_resized}, but returns the variant record instead of its data.
|
|
78
|
+
#
|
|
79
|
+
# @param size [Vector2d] the size to render, in pixels
|
|
80
|
+
# @return [DynamicImage::Variant, nil] the variant, or nil if the record isn't persisted
|
|
56
81
|
def variant_for(size)
|
|
57
82
|
return nil unless record.persisted?
|
|
58
83
|
|
|
@@ -61,6 +86,9 @@ module DynamicImage
|
|
|
61
86
|
find_variant(size)
|
|
62
87
|
end
|
|
63
88
|
|
|
89
|
+
# The format the image is rendered in. Defaults to the format of the stored image.
|
|
90
|
+
#
|
|
91
|
+
# @return [DynamicImage::Format]
|
|
64
92
|
def format
|
|
65
93
|
DynamicImage::Format.find(@format_name) || record_format
|
|
66
94
|
end
|
|
@@ -73,18 +101,26 @@ module DynamicImage
|
|
|
73
101
|
# * Optimizes GIFs
|
|
74
102
|
# * Performs format conversion if the requested format is different
|
|
75
103
|
#
|
|
76
|
-
#
|
|
104
|
+
# @yield [image] an optional block to transform the image before it is written out
|
|
105
|
+
# @yieldparam image [DynamicImage::ImageProcessor] the processor
|
|
106
|
+
# @yieldreturn [DynamicImage::ImageProcessor] the transformed processor
|
|
107
|
+
# @return [String] the image data as a binary string
|
|
108
|
+
# @raise [DynamicImage::Errors::InvalidImage] if the record isn't a valid image, or if the stored data can't be
|
|
109
|
+
# processed
|
|
77
110
|
#
|
|
78
|
-
#
|
|
111
|
+
# @example
|
|
112
|
+
# processed = DynamicImage::ProcessedImage.new(image, format: :jpeg)
|
|
79
113
|
# jpg_data = processed.normalized
|
|
80
|
-
#
|
|
81
|
-
# Returns a binary string.
|
|
82
114
|
def normalized
|
|
83
115
|
require_valid_image!
|
|
84
116
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
117
|
+
record.with_data_file do |path|
|
|
118
|
+
image = DynamicImage::ImageProcessor.new(path)
|
|
119
|
+
image = yield(image) if block_given?
|
|
120
|
+
image.convert(format).read
|
|
121
|
+
end
|
|
122
|
+
rescue Vips::Error => e
|
|
123
|
+
raise DynamicImage::Errors::InvalidImage, e.message
|
|
88
124
|
end
|
|
89
125
|
|
|
90
126
|
private
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DynamicImage
|
|
4
|
+
# = DynamicImage Ratio
|
|
5
|
+
#
|
|
6
|
+
# Coerces an aspect ratio into a float. Strings, rationals, floats and vectors are all accepted.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# DynamicImage::Ratio.parse("16:9") # => 1.7777..
|
|
10
|
+
# DynamicImage::Ratio.parse("16x9") # => 1.7777..
|
|
11
|
+
# DynamicImage::Ratio.parse(Rational(16, 9)) # => 1.7777..
|
|
12
|
+
# DynamicImage::Ratio.parse(Vector2d(16, 9)) # => 1.7777..
|
|
13
|
+
module Ratio
|
|
14
|
+
class << self
|
|
15
|
+
# Returns the ratio as width divided by height.
|
|
16
|
+
#
|
|
17
|
+
# @param value [Numeric, Vector2d, String, nil] the ratio, as a number, a vector, or a string written with
|
|
18
|
+
# <tt>:</tt>, <tt>x</tt> or <tt>/</tt> between the two sides
|
|
19
|
+
# @return [Float, nil]
|
|
20
|
+
# @raise [ArgumentError] if the value isn't a usable ratio
|
|
21
|
+
def parse(value)
|
|
22
|
+
return if value.nil?
|
|
23
|
+
|
|
24
|
+
ratio = coerce(value)
|
|
25
|
+
unless ratio.is_a?(Float) && ratio.positive? && ratio.finite?
|
|
26
|
+
raise ArgumentError, "invalid ratio: #{value.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
ratio
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def coerce(value)
|
|
35
|
+
case value
|
|
36
|
+
when Numeric then value.to_f
|
|
37
|
+
when Vector2d then value.x.to_f / value.y
|
|
38
|
+
when String then value.split(%r{[:x/]}).map(&:to_f).reduce(:/)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -3,12 +3,23 @@
|
|
|
3
3
|
module DynamicImage
|
|
4
4
|
# = DynamicImage Routing
|
|
5
5
|
#
|
|
6
|
-
# Extends +ActionDispatch::Routing::Mapper+ and provides a shortcut for
|
|
7
|
-
#
|
|
6
|
+
# Extends +ActionDispatch::Routing::Mapper+ and provides a shortcut for defining routes for
|
|
7
|
+
# +DynamicImage::Controller+.
|
|
8
8
|
module Routing
|
|
9
|
-
# Declares an image resource.
|
|
9
|
+
# Declares an image resource, routing +show+ along with the +uncropped+, +original+ and +download+ member actions.
|
|
10
10
|
#
|
|
11
|
+
# The default path includes the digest and an optional size. Pass +:path+ to change it if the resource collides
|
|
12
|
+
# with a directory in +public+.
|
|
13
|
+
#
|
|
14
|
+
# @param resource_name [Symbol, String] the resource
|
|
15
|
+
# @param options [Hash] options passed to +resources+, merged over the defaults
|
|
16
|
+
# @return [void]
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
11
19
|
# image_resources :avatars
|
|
20
|
+
#
|
|
21
|
+
# @example Keeping clear of public/images
|
|
22
|
+
# image_resources :images, path: "dynamic_images/:digest(/:size)"
|
|
12
23
|
def image_resources(resource_name, options = {})
|
|
13
24
|
options = {
|
|
14
25
|
path: "#{resource_name}/:digest(/:size)",
|