dynamic_image 3.0.9 → 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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +393 -67
  3. data/app/models/dynamic_image/variant.rb +10 -0
  4. data/lib/dynamic_image/backfill.rb +85 -0
  5. data/lib/dynamic_image/belongs_to.rb +22 -0
  6. data/lib/dynamic_image/breakpoints.rb +93 -0
  7. data/lib/dynamic_image/controller.rb +51 -7
  8. data/lib/dynamic_image/digest_verifier.rb +20 -9
  9. data/lib/dynamic_image/engine.rb +3 -0
  10. data/lib/dynamic_image/errors.rb +30 -0
  11. data/lib/dynamic_image/format.rb +149 -8
  12. data/lib/dynamic_image/format_negotiator.rb +84 -0
  13. data/lib/dynamic_image/helper/formats.rb +57 -0
  14. data/lib/dynamic_image/helper/pictures.rb +124 -0
  15. data/lib/dynamic_image/helper.rb +100 -60
  16. data/lib/dynamic_image/image_processor/colors.rb +3 -7
  17. data/lib/dynamic_image/image_processor/frames.rb +10 -1
  18. data/lib/dynamic_image/image_processor/transform.rb +17 -2
  19. data/lib/dynamic_image/image_processor.rb +33 -6
  20. data/lib/dynamic_image/image_reader.rb +19 -1
  21. data/lib/dynamic_image/image_sizing.rb +51 -19
  22. data/lib/dynamic_image/metadata.rb +56 -5
  23. data/lib/dynamic_image/model/dimensions.rb +32 -13
  24. data/lib/dynamic_image/model/transformations.rb +47 -11
  25. data/lib/dynamic_image/model/validations.rb +15 -8
  26. data/lib/dynamic_image/model/variants.rb +6 -3
  27. data/lib/dynamic_image/model.rb +58 -34
  28. data/lib/dynamic_image/picture/format_policy.rb +72 -0
  29. data/lib/dynamic_image/picture.rb +215 -0
  30. data/lib/dynamic_image/processed_image.rb +48 -14
  31. data/lib/dynamic_image/ratio.rb +43 -0
  32. data/lib/dynamic_image/routing.rb +14 -3
  33. data/lib/dynamic_image/schema.rb +47 -0
  34. data/lib/dynamic_image/version.rb +1 -1
  35. data/lib/dynamic_image.rb +61 -1
  36. data/lib/rails/generators/dynamic_image/resource/USAGE +17 -0
  37. data/lib/rails/generators/dynamic_image/resource/resource_generator.rb +57 -21
  38. data/lib/rails/generators/dynamic_image/resource/templates/create_table_migration.rb.tt +32 -0
  39. data/lib/rails/generators/dynamic_image/upgrade/USAGE +17 -0
  40. data/lib/rails/generators/dynamic_image/upgrade/templates/upgrade_migration.rb.tt +10 -0
  41. data/lib/rails/generators/dynamic_image/upgrade/upgrade_generator.rb +111 -0
  42. data/lib/tasks/dynamic_image.rake +25 -0
  43. metadata +23 -12
  44. data/lib/dynamic_image/jobs/create_variant.rb +0 -22
  45. data/lib/dynamic_image/jobs.rb +0 -3
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DynamicImage
4
+ module Helper
5
+ # = DynamicImage Helper Formats
6
+ #
7
+ # Resolves the <tt>format:</tt> option the helpers in {DynamicImage::Helper} pass to the router.
8
+ #
9
+ # A symbol forces that format. An array, or nothing at all, is negotiated against the image. The default list
10
+ # depends on where the view is rendered: {DynamicImage.mailer_formats} in a mailer,
11
+ # {DynamicImage.default_formats} everywhere else.
12
+ #
13
+ # The +original+ and +download+ actions serve the stored file untouched, so they always get the record's own
14
+ # format. Negotiating one would put an extension on the URL that the response doesn't match.
15
+ #
16
+ # @see DynamicImage::FormatNegotiator
17
+ module Formats
18
+ private
19
+
20
+ def dynamic_image_format(record, format, action = nil)
21
+ return stored_format_for_image(record) if raw_action?(action)
22
+ return format unless format.nil? || format.is_a?(Array)
23
+
24
+ negotiated_format_for_image(record, format || accepted_image_formats)
25
+ end
26
+
27
+ def raw_action?(action)
28
+ %w[original download].include?(action.to_s)
29
+ end
30
+
31
+ def stored_format_for_image(record)
32
+ mime_format(DynamicImage::Format.content_type(record.content_type))
33
+ end
34
+
35
+ def negotiated_format_for_image(record, accepted)
36
+ mime_format(DynamicImage::FormatNegotiator.new(record)
37
+ .negotiate(accepted))
38
+ end
39
+
40
+ def mime_format(format)
41
+ format.mime_type.to_sym
42
+ end
43
+
44
+ def accepted_image_formats
45
+ if mailer_view?
46
+ DynamicImage.mailer_formats
47
+ else
48
+ DynamicImage.default_formats
49
+ end
50
+ end
51
+
52
+ def mailer_view?
53
+ defined?(ActionMailer::Base) && controller.is_a?(ActionMailer::Base)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DynamicImage
4
+ module Helper
5
+ # = DynamicImage Helper Pictures
6
+ #
7
+ # Renders responsive images: a <tt>picture</tt> element with a WebP <tt>source</tt> covering a range of widths,
8
+ # and an <tt>img</tt> fallback in a format everything understands.
9
+ #
10
+ # These helpers take an optional +ratio+ instead of a +size+, leaving the width to the browser.
11
+ # {DynamicImage::Helper#dynamic_image_tag} renders at a size you pick; these render across a range of widths.
12
+ #
13
+ # @see DynamicImage::Picture
14
+ # @see DynamicImage::Breakpoints
15
+ module Pictures
16
+ # Returns a {DynamicImage::Picture} for the record, which is what the tag helpers render.
17
+ #
18
+ # @param record_or_array [DynamicImage::Model, Array] the record, or an array of records for a nested route
19
+ # @param options [Hash] sizing and routing options, as taken by {DynamicImage::Picture#initialize}
20
+ # @return [DynamicImage::Picture]
21
+ #
22
+ # @example
23
+ # picture = dynamic_picture(image, ratio: "16:9")
24
+ # picture.srcset # => "/images/… 420w, /images/… 590w, …"
25
+ # picture.sources # => [{ type: "image/webp", srcset: "…" }]
26
+ def dynamic_picture(record_or_array, options = {})
27
+ DynamicImage::Picture.new(self, record_or_array, picture_options(options.symbolize_keys))
28
+ end
29
+
30
+ # Renders a responsive <tt>picture</tt> element.
31
+ #
32
+ # The <tt>img</tt> carries the +width+ and +height+ of the fallback, which needn't match whichever candidate is
33
+ # chosen.
34
+ #
35
+ # Any other options are passed on to {DynamicImage::Helper#dynamic_image_tag}.
36
+ #
37
+ # @param record_or_array [DynamicImage::Model, Array] the record, or an array of records for a nested route
38
+ # @param options [Hash] sizing options, routing options and HTML attributes
39
+ # @option options [Numeric, Vector2d, String] :ratio The aspect ratio to crop to. Implies cropping.
40
+ # @option options [String] :sizes The +sizes+ attribute, telling the browser how large the image will be
41
+ # rendered.
42
+ # @option options [Range, Array<Integer>, Integer] :breakpoints The widths to offer, overriding
43
+ # {DynamicImage.default_breakpoints}
44
+ # @option options [Numeric] :step The step between breakpoints, overriding {DynamicImage.breakpoint_step}
45
+ # @option options [Integer] :fallback_width The width to ask for the <tt>img</tt>, overriding
46
+ # {DynamicImage.picture_fallback_width}
47
+ # @return [String] the picture element
48
+ #
49
+ # @example
50
+ # dynamic_picture_tag(image, sizes: "50vw", alt: "A kitten")
51
+ # dynamic_picture_tag(image, ratio: "16:9", sizes: "50vw")
52
+ def dynamic_picture_tag(record_or_array, options = {})
53
+ options = options.symbolize_keys
54
+ picture = dynamic_picture(record_or_array, options)
55
+ source = picture_source_tag(picture) unless picture.sources.empty?
56
+ image = picture_fallback_tag(record_or_array, picture, options)
57
+
58
+ tag.picture { safe_join([source, image].compact) }
59
+ end
60
+
61
+ # Renders a single <tt>source</tt> element, for composing a <tt>picture</tt> by hand.
62
+ #
63
+ # Use it for different crops per media query: several sources, each with its own crop and query. The browser
64
+ # takes the first one whose +media+ matches and whose +type+ it supports, so put the specific queries first
65
+ # and the unconditional one last.
66
+ #
67
+ # @param record_or_array [DynamicImage::Model, Array] the record, or an array of records for a nested route
68
+ # @param options [Hash] sizing and routing options
69
+ # @option options [String] :media The media query this source answers
70
+ # @option options [Symbol, Array<Symbol>] :format The format to render in. A symbol forces that format, an
71
+ # array is negotiated. Defaults to WebP.
72
+ # @return [String, nil] the source element
73
+ #
74
+ # @example Different crops per media query
75
+ # <picture>
76
+ # <%= dynamic_picture_source_tag(image, ratio: "21:9",
77
+ # media: "(min-width: 1000px)") %>
78
+ # <%= dynamic_picture_source_tag(image, ratio: "1:1") %>
79
+ # <%= dynamic_image_tag(image, size: "1200x1200", crop: true) %>
80
+ # </picture>
81
+ #
82
+ # @example A source for browsers without WebP
83
+ # dynamic_picture_source_tag(image, ratio: "21:9",
84
+ # format: DynamicImage::COMPATIBLE_FORMATS)
85
+ def dynamic_picture_source_tag(record_or_array, options = {})
86
+ options = options.symbolize_keys
87
+
88
+ picture_source_tag(dynamic_picture(record_or_array, options), options[:media])
89
+ end
90
+
91
+ private
92
+
93
+ def picture_source_tag(picture, media = nil)
94
+ srcset = picture.srcset
95
+ return unless srcset
96
+
97
+ tag.source(type: picture.type, srcset:, sizes: picture.sizes, media:)
98
+ end
99
+
100
+ def picture_fallback_tag(record_or_array, picture, options)
101
+ dynamic_image_tag(
102
+ record_or_array,
103
+ options.except(*DynamicImage::Picture::OPTIONS, :media)
104
+ .merge(img_srcset_attributes(picture))
105
+ .merge(size: picture.fallback_size,
106
+ crop: picture.crop?,
107
+ format: mime_format(picture.fallback_format))
108
+ )
109
+ end
110
+
111
+ # The candidates ride on the img itself when no source carries them.
112
+ def img_srcset_attributes(picture)
113
+ return {} unless picture.sources.empty?
114
+
115
+ { srcset: picture.srcset, sizes: picture.sizes }
116
+ end
117
+
118
+ # Passes on only the options the picture and the router understand.
119
+ def picture_options(options)
120
+ options.slice(*DynamicImage::Picture::OPTIONS, *allowed_dynamic_image_url_options)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -1,38 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "dynamic_image/helper/formats"
4
+ require "dynamic_image/helper/pictures"
5
+
3
6
  module DynamicImage
4
7
  # = DynamicImage Helper
5
8
  #
6
- # Provides helper methods for rendering and linking to images.
9
+ # Helper methods for rendering and linking to images.
10
+ #
11
+ # Every URL is signed with an HMAC digest of the action, the record id and the size, and the controller rejects
12
+ # anything that doesn't match. URLs can only be built server-side.
13
+ #
14
+ # +_path+ returns a relative path and +_url+ an absolute one. +_tag+ renders an image tag, and is available for
15
+ # +dynamic_image+ and +uncropped_dynamic_image+ only. +original+ and +download+ point at the stored file, which is
16
+ # never rendered.
17
+ #
18
+ # @see DynamicImage::ImageSizing for how sizes are calculated
19
+ # @see DynamicImage::Helper::Formats for how the format is chosen
20
+ # @see DynamicImage::Helper::Pictures for responsive images
7
21
  module Helper
8
- # Returns the path for a DynamicImage::Model record.
9
- # Takes the same options as +dynamic_image_url+
22
+ include DynamicImage::Helper::Formats
23
+ include DynamicImage::Helper::Pictures
24
+
25
+ # Returns the path for a {DynamicImage::Model} record. Takes the same options as {#dynamic_image_url}.
26
+ #
27
+ # @param record_or_array [DynamicImage::Model, Array] the record, or an array of records for a nested route
28
+ # @param options [Hash] sizing and routing options
29
+ # @return [String]
10
30
  def dynamic_image_path(record_or_array, options = {})
11
31
  dynamic_image_url(record_or_array, { routing_type: :path }.merge(options))
12
32
  end
13
33
 
14
- # Returns an HTML image tag for the record. If no size is given, it will
15
- # render at the original size.
34
+ # Returns an HTML image tag for the record, with +width+ and +height+ set to the rendered size. Without a
35
+ # size, it renders at the original size.
16
36
  #
17
- # ==== Options
18
- # * <tt>:alt</tt>: If no alt text is given, it will default to the
19
- # filename of the uploaded image.
37
+ # See {#dynamic_image_url} for sizing and cropping. Options supported by +polymorphic_url+ are passed to the
38
+ # router, and any others are added as HTML attributes.
20
39
  #
21
- # See +dynamic_image_url+ for info on how to size and cropping. Options
22
- # supported by +polymorphic_url+ will be passed to the router. Any other
23
- # options will be added as HTML attributes.
40
+ # Unless an +alt+ option is given, the alt text is resolved from {DynamicImage::Model#alt_text}.
41
+ # Pass <tt>alt: ""</tt> to mark an image as decorative.
24
42
  #
25
- # ==== Examples
43
+ # @param record_or_array [DynamicImage::Model, Array] the record, or an array of records for a nested route
44
+ # @param options [Hash] sizing options, routing options and HTML attributes
45
+ # @return [String]
26
46
  #
47
+ # @example
27
48
  # image = Image.find(params[:id])
49
+ #
28
50
  # dynamic_image_tag(image)
29
- # # => <img height="200" src="..." width="320" />
30
- # dynamic_image_tag(image, size: "100x100", alt="Avatar")
31
- # # => <img alt="Avatar" height="62" src="..." width="100" />
51
+ # # => <img src="..." width="320" height="200" />
52
+ #
53
+ # dynamic_image_tag(image, size: "100x100", alt: "Avatar")
54
+ # # => <img alt="Avatar" src="..." width="100" height="62" />
32
55
  def dynamic_image_tag(record_or_array, options = {})
56
+ record = extract_dynamic_image_record(record_or_array)
33
57
  size = fit_size!(record_or_array, options)
34
58
  url_options = options.extract!(*allowed_dynamic_image_url_options)
35
- html_options = { size: }.merge(options)
59
+ html_options = { size:, alt: record.alt_text }.merge(options)
36
60
 
37
61
  image_tag(dynamic_image_path_with_size(record_or_array,
38
62
  size,
@@ -40,26 +64,33 @@ module DynamicImage
40
64
  html_options)
41
65
  end
42
66
 
43
- # Returns the URL for a DynamicImage::Model record.
44
- #
45
- # ==== Options
67
+ # Returns the URL for a {DynamicImage::Model} record.
46
68
  #
47
- # * <tt>:size</tt> - Desired image size, supplied as "{width}x{height}".
48
- # The image will be scaled to fit. A partial size like "100x" or "x100"
49
- # can be given, if you want a fixed width or height.
50
- # * <tt>:crop</tt> - If true, the image will be cropped to the given size.
51
- # * <tt>:upscale</tt> - By default, DynamicImage only scale images down,
52
- # never up. Pass <tt>upscale: true</tt> to force upscaling.
69
+ # @param record_or_array [DynamicImage::Model, Array] the record, or an array of records for a nested route
70
+ # @param options [Hash] sizing and routing options
71
+ # @option options [String] :size Desired image size, as <tt>"{width}x{height}"</tt>. The image is scaled to fit. A
72
+ # partial size like <tt>"100x"</tt> or <tt>"x100"</tt> can be given for a fixed width or height.
73
+ # @option options [Boolean] :crop Crop the image to the given size instead of fitting it. Both dimensions are
74
+ # required.
75
+ # @option options [Boolean] :upscale By default images are only scaled down, never up. Pass true to force
76
+ # upscaling.
77
+ # @option options [Symbol, Array<Symbol>] :format Render in a different format. A symbol forces that format,
78
+ # an array is negotiated against the image by {DynamicImage::FormatNegotiator}. Defaults to
79
+ # {DynamicImage.default_formats}, or {DynamicImage.mailer_formats} in a mailer view.
80
+ # @return [String]
81
+ # @raise [DynamicImage::Errors::InvalidSizeOptions] if <tt>crop: true</tt> is given without both dimensions
53
82
  #
54
83
  # Any options supported by +polymorphic_url+ are also accepted.
55
84
  #
56
- # ==== Examples
57
- #
85
+ # @example
58
86
  # image = Image.find(params[:id])
87
+ #
59
88
  # dynamic_image_url(image)
60
89
  # # => "http://example.com/images/96...d1/300x187/1-2014062020...00.jpg"
90
+ #
61
91
  # dynamic_image_url(image, size: '100x100')
62
92
  # # => "http://example.com/images/72...c2/100x62/1-2014062020...00.jpg"
93
+ #
63
94
  # dynamic_image_url(image, size: '100x100', crop: true)
64
95
  # # => "http://example.com/images/a4...6b/100x100/1-2014062020...00.jpg"
65
96
  def dynamic_image_url(record_or_array, options = {})
@@ -67,46 +98,68 @@ module DynamicImage
67
98
  dynamic_image_url_with_size(record_or_array, size, options)
68
99
  end
69
100
 
70
- # Returns a path to the original uploaded file for download,
71
- # without any processing applied. Sizing options are not
72
- # supported.
101
+ # Returns a path to the original uploaded file, served as an attachment so the browser downloads it. The file
102
+ # is not processed, and sizing options are ignored. The URL carries the stored format's extension, and
103
+ # +:format+ has no effect.
104
+ #
105
+ # @param record_or_array [DynamicImage::Model, Array] the record
106
+ # @param options [Hash] routing options
107
+ # @return [String]
73
108
  def download_dynamic_image_path(record_or_array, options = {})
74
109
  dynamic_image_path(record_or_array, { action: :download }.merge(options))
75
110
  end
76
111
 
77
- # Returns a URL to the original uploaded file for download,
78
- # without any processing applied. Sizing options are not
79
- # supported.
112
+ # Same as {#download_dynamic_image_path}, but returns an absolute URL.
113
+ #
114
+ # @param record_or_array [DynamicImage::Model, Array] the record
115
+ # @param options [Hash] routing options
116
+ # @return [String]
80
117
  def download_dynamic_image_url(record_or_array, options = {})
81
118
  dynamic_image_url(record_or_array, { action: :download }.merge(options))
82
119
  end
83
120
 
84
- # Returns a path to the original uploaded file, without any processing
85
- # applied. Sizing options are not supported.
121
+ # Returns a path to the original uploaded file, exactly as it was uploaded. The file is not processed, and
122
+ # sizing options are ignored. The URL carries the stored format's extension, and +:format+ has no effect.
123
+ #
124
+ # @param record_or_array [DynamicImage::Model, Array] the record
125
+ # @param options [Hash] routing options
126
+ # @return [String]
86
127
  def original_dynamic_image_path(record_or_array, options = {})
87
128
  dynamic_image_path(record_or_array, { action: :original }.merge(options))
88
129
  end
89
130
 
90
- # Returns a URL to the original uploaded file, without any processing
91
- # applied. Sizing options are not supported.
131
+ # Same as {#original_dynamic_image_path}, but returns an absolute URL.
132
+ #
133
+ # @param record_or_array [DynamicImage::Model, Array] the record
134
+ # @param options [Hash] routing options
135
+ # @return [String]
92
136
  def original_dynamic_image_url(record_or_array, options = {})
93
137
  dynamic_image_url(record_or_array, { action: :original }.merge(options))
94
138
  end
95
139
 
96
- # Same as +dynamic_image_path+, but points to an image with any
97
- # pre-cropping disabled.
140
+ # Same as {#dynamic_image_path}, but points to an image with any pre-cropping disabled.
141
+ #
142
+ # @param record_or_array [DynamicImage::Model, Array] the record
143
+ # @param options [Hash] sizing and routing options
144
+ # @return [String]
98
145
  def uncropped_dynamic_image_path(record_or_array, options = {})
99
146
  dynamic_image_path(record_or_array, { action: :uncropped }.merge(options))
100
147
  end
101
148
 
102
- # Same as +dynamic_image_tag+, but renders an image with any
103
- # pre-cropping disabled.
149
+ # Same as {#dynamic_image_tag}, but renders an image with any pre-cropping disabled.
150
+ #
151
+ # @param record_or_array [DynamicImage::Model, Array] the record
152
+ # @param options [Hash] sizing options, routing options and HTML attributes
153
+ # @return [String]
104
154
  def uncropped_dynamic_image_tag(record_or_array, options = {})
105
155
  dynamic_image_tag(record_or_array, { action: :uncropped }.merge(options))
106
156
  end
107
157
 
108
- # Same as +dynamic_image_url+, but points to an image with any
109
- # pre-cropping disabled.
158
+ # Same as {#dynamic_image_url}, but points to an image with any pre-cropping disabled.
159
+ #
160
+ # @param record_or_array [DynamicImage::Model, Array] the record
161
+ # @param options [Hash] sizing and routing options
162
+ # @return [String]
110
163
  def uncropped_dynamic_image_url(record_or_array, options = {})
111
164
  dynamic_image_url(record_or_array, { action: :uncropped }.merge(options))
112
165
  end
@@ -119,10 +172,6 @@ module DynamicImage
119
172
  action routing_type ]
120
173
  end
121
174
 
122
- def default_format_for_image(record)
123
- Mime::Type.lookup(record.safe_content_type).to_sym
124
- end
125
-
126
175
  def dynamic_image_digest(record, action, size = nil)
127
176
  key = [action || "show", record.id, size].compact.join("-")
128
177
  DynamicImage.digest_verifier.generate(key)
@@ -136,12 +185,10 @@ module DynamicImage
136
185
 
137
186
  def dynamic_image_url_with_size(record_or_array, size = nil, options = {})
138
187
  record = extract_dynamic_image_record(record_or_array)
139
- options = {
140
- routing_type: :url,
141
- action: nil,
142
- format: default_format_for_image(record),
143
- size:
144
- }.merge(options)
188
+ options = { routing_type: :url, action: nil, size: }.merge(options)
189
+ options[:routing_type] = :url if mailer_view?
190
+ options[:format] = dynamic_image_format(record, options[:format],
191
+ options[:action])
145
192
  options[:digest] =
146
193
  dynamic_image_digest(record, options[:action], options[:size])
147
194
  polymorphic_url(record_or_array, options)
@@ -156,13 +203,6 @@ module DynamicImage
156
203
  end
157
204
  end
158
205
 
159
- def filename_to_alt(str)
160
- File.basename(str, ".*")
161
- .sub(/-[[:xdigit:]]{32,64}\z/, "")
162
- .tr("-_", " ")
163
- .capitalize
164
- end
165
-
166
206
  def fit_size!(record_or_array, options)
167
207
  record = extract_dynamic_image_record(record_or_array)
168
208
  action = options[:action].try(:to_s)
@@ -4,10 +4,8 @@ module DynamicImage
4
4
  class ImageProcessor
5
5
  # = ImageProcessor::Colors
6
6
  #
7
- # Performs the necessary profile conversions on the image. All
8
- # images are converted to the sRGB colorspace using either the
9
- # embedded profile, or the built-in generic profile. Grayscale
10
- # images are converted back to grayscale after processing.
7
+ # Converts color profiles. All images are converted to the sRGB colorspace using either the embedded profile
8
+ # or the built-in generic one. Grayscale images are converted back to grayscale after processing.
11
9
  module Colors
12
10
  private
13
11
 
@@ -22,9 +20,7 @@ module DynamicImage
22
20
  end
23
21
 
24
22
  def screen_profile(image)
25
- if !icc_profile?(image) && %i[rgb b-w].include?(image.interpretation)
26
- return image
27
- end
23
+ return image if !icc_profile?(image) && %i[rgb b-w].include?(image.interpretation)
28
24
 
29
25
  target_space = image.interpretation == :"b-w" ? "b-w" : "srgb"
30
26
  icc_transform_srgb(image).colourspace(target_space)
@@ -2,13 +2,22 @@
2
2
 
3
3
  module DynamicImage
4
4
  class ImageProcessor
5
+ # = ImageProcessor::Frames
6
+ #
7
+ # Support for animated GIF and WebP images, which vips represents as a single tall image with a +page-height+ of
8
+ # one frame. Operations are applied to each frame and the results reassembled.
5
9
  module Frames
6
10
  # Extracts a single frame from a multi-frame image.
11
+ #
12
+ # @param index [Integer] the frame to extract, zero based
13
+ # @return [DynamicImage::ImageProcessor] a new processor
7
14
  def frame(index)
8
15
  apply extract_frame(index)
9
16
  end
10
17
 
11
- # Returns the number of frames.
18
+ # Returns the number of frames. Still images have one.
19
+ #
20
+ # @return [Integer]
12
21
  def frame_count
13
22
  image.get("height") / size.y
14
23
  end
@@ -2,8 +2,16 @@
2
2
 
3
3
  module DynamicImage
4
4
  class ImageProcessor
5
+ # = ImageProcessor::Transform
6
+ #
7
+ # Cropping, resizing and rotation. Each operation is applied to every frame of an animated image.
5
8
  module Transform
6
- # Crops the image
9
+ # Crops the image.
10
+ #
11
+ # @param crop_size [Vector2d] the size of the crop
12
+ # @param crop_start [Vector2d] its top left corner
13
+ # @return [DynamicImage::ImageProcessor] a new processor
14
+ # @raise [DynamicImage::Errors::InvalidTransformation] if the crop falls outside the image
7
15
  def crop(crop_size, crop_start)
8
16
  return self if crop_start == Vector2d(0, 0) && crop_size == size
9
17
 
@@ -17,7 +25,10 @@ module DynamicImage
17
25
  end
18
26
  end
19
27
 
20
- # Resize the image to a new size.
28
+ # Resize the image to a new size. The aspect ratio is not preserved; crop first if that matters.
29
+ #
30
+ # @param new_size [Vector2d] the size to resize to
31
+ # @return [DynamicImage::ImageProcessor] a new processor
21
32
  def resize(new_size)
22
33
  new_size = Vector2d(new_size)
23
34
  apply image.thumbnail_image(new_size.x.to_i,
@@ -27,6 +38,10 @@ module DynamicImage
27
38
  end
28
39
 
29
40
  # Rotates the image. The rotation must be a multiple of 90 degrees.
41
+ #
42
+ # @param degrees [Integer] the angle
43
+ # @return [DynamicImage::ImageProcessor] a new processor
44
+ # @raise [DynamicImage::Errors::InvalidTransformation] if the angle isn't a multiple of 90
30
45
  def rotate(degrees)
31
46
  degrees = degrees.to_i % 360
32
47
  return self if degrees.zero?
@@ -7,10 +7,17 @@ require "dynamic_image/image_processor/transform"
7
7
  module DynamicImage
8
8
  # = ImageProcessor
9
9
  #
10
- # This is the image processing pipeline.
10
+ # The image processing pipeline.
11
11
  #
12
- # ==== Example:
12
+ # Every operation returns a new processor instead of modifying the one it was called on, so operations chain.
13
+ # Images are converted to sRGB and EXIF rotation is applied when the processor is built, so the pipeline always
14
+ # starts from a normalized image.
13
15
  #
16
+ # @see DynamicImage::ImageProcessor::Colors
17
+ # @see DynamicImage::ImageProcessor::Frames
18
+ # @see DynamicImage::ImageProcessor::Transform
19
+ #
20
+ # @example
14
21
  # DynamicImage::ImageProcessor
15
22
  # .new(file)
16
23
  # .crop(crop_start, crop_size)
@@ -22,8 +29,16 @@ module DynamicImage
22
29
  include DynamicImage::ImageProcessor::Frames
23
30
  include DynamicImage::ImageProcessor::Transform
24
31
 
32
+ # @!attribute [r] image
33
+ # @return [Vips::Image]
34
+ # @!attribute [r] target_format
35
+ # @return [DynamicImage::Format] the format it will be written in
25
36
  attr_reader :image, :target_format
26
37
 
38
+ # @param image [Vips::Image, Pathname, IO, String] the image, either an already loaded vips image or something
39
+ # {DynamicImage::ImageReader} can read
40
+ # @param target_format [DynamicImage::Format, nil] the format to write in, defaulting to the format the image was
41
+ # read from
27
42
  def initialize(image, target_format: nil)
28
43
  if image.is_a?(Vips::Image)
29
44
  @image = image
@@ -36,10 +51,14 @@ module DynamicImage
36
51
  end
37
52
 
38
53
  # Convert the image to a different format.
54
+ #
55
+ # Converting a multi-frame image to a format that doesn't support animation keeps the first frame.
56
+ #
57
+ # @param new_format [DynamicImage::Format, Symbol, String] the format to convert to
58
+ # @return [DynamicImage::ImageProcessor] a new processor
39
59
  def convert(new_format)
40
- unless new_format.is_a?(DynamicImage::Format)
41
- new_format = DynamicImage::Format.find(new_format)
42
- end
60
+ new_format = DynamicImage::Format.find(new_format) unless new_format.is_a?(DynamicImage::Format)
61
+
43
62
  if frame_count > 1 && !new_format.animated?
44
63
  self.class.new(extract_frame(0), target_format: new_format)
45
64
  else
@@ -48,12 +67,17 @@ module DynamicImage
48
67
  end
49
68
 
50
69
  # Returns the image data as a binary string.
70
+ #
71
+ # @return [String] the encoded image
51
72
  def read
52
73
  image.write_to_buffer(target_format.extension,
53
74
  **target_format.save_options)
54
75
  end
55
76
 
56
- # Returns the image size as a Vector2d.
77
+ # Returns the image size as a Vector2d. For multi-frame images this is the size of a single frame, not of the
78
+ # filmstrip vips holds them in.
79
+ #
80
+ # @return [Vector2d]
57
81
  def size
58
82
  Vector2d.new(
59
83
  image.get("width"),
@@ -64,6 +88,9 @@ module DynamicImage
64
88
  end
65
89
 
66
90
  # Write the image to a file.
91
+ #
92
+ # @param path [String] the path to write to
93
+ # @return [void]
67
94
  def write(path)
68
95
  image.write_to_file(path, **target_format.save_options)
69
96
  end
@@ -1,17 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DynamicImage
4
+ # = DynamicImage Image Reader
5
+ #
6
+ # Reads an image into vips, identifying the format from the file header and not from anything the client claims.
7
+ # Accepts a Pathname, an IO object or a binary string.
8
+ #
9
+ # Animated formats are opened with all their frames.
4
10
  class ImageReader
5
- HEADER_BYTES = 12
11
+ # Number of bytes needed to identify a format.
12
+ HEADER_BYTES = 32
6
13
 
14
+ # @param data [Pathname, IO, String] the image
7
15
  def initialize(data)
8
16
  @data = data
9
17
  end
10
18
 
19
+ # The format of the image, sniffed from its header.
20
+ #
21
+ # @return [DynamicImage::Format, nil] the format, if recognized
11
22
  def format
12
23
  DynamicImage::Format.sniff(file_header)
13
24
  end
14
25
 
26
+ # Reads the image.
27
+ #
28
+ # @return [Vips::Image]
29
+ # @raise [DynamicImage::Errors::InvalidHeader] if the data isn't in a recognized format
15
30
  def read
16
31
  raise DynamicImage::Errors::InvalidHeader unless valid_header?
17
32
 
@@ -23,6 +38,9 @@ module DynamicImage
23
38
  end
24
39
  end
25
40
 
41
+ # Returns true if the header belongs to a supported format.
42
+ #
43
+ # @return [Boolean]
26
44
  def valid_header?
27
45
  format ? true : false
28
46
  end