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,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DynamicImage
4
+ # = DynamicImage Breakpoints
5
+ #
6
+ # Computes the candidate widths for a +srcset+, given the widest the image can be rendered at.
7
+ #
8
+ # A range steps down geometrically from the available width, an array lists the widths outright, and a single
9
+ # number gives one candidate.
10
+ #
11
+ # @example
12
+ # DynamicImage::Breakpoints.new(320..3200).widths(2000)
13
+ # # => [370, 520, 720, 1020, 1420, 2000]
14
+ #
15
+ # DynamicImage::Breakpoints.new([400, 800, 1600]).widths(1000)
16
+ # # => [400, 800]
17
+ #
18
+ # @see DynamicImage::Picture
19
+ class Breakpoints
20
+ # Candidate widths are snapped down to a multiple of this, so that images of similar size share widths.
21
+ SNAP = 10
22
+
23
+ # @!attribute [r] spec
24
+ # @return [Range, Array<Integer>, Integer]
25
+ # @!attribute [r] step
26
+ # @return [Float] the ratio between two candidates
27
+ attr_reader :spec, :step
28
+
29
+ # @param spec [Range, Array<Integer>, Integer, nil] the breakpoint spec. Defaults to
30
+ # {DynamicImage.default_breakpoints}.
31
+ # @param step [Numeric, nil] the ratio between two candidates in a range. Defaults to
32
+ # {DynamicImage.breakpoint_step}.
33
+ # @raise [ArgumentError] if the step or range would never terminate
34
+ def initialize(spec = nil, step: nil)
35
+ @spec = spec.nil? ? DynamicImage.default_breakpoints : spec
36
+ @step = (step || DynamicImage.breakpoint_step).to_f
37
+ validate!
38
+ end
39
+
40
+ # Returns the candidate widths, smallest first.
41
+ #
42
+ # The largest candidate is always the available width, whether or not it falls inside the range. An image narrower
43
+ # than the range gets a single candidate.
44
+ #
45
+ # @param available [Integer] the widest the image can be rendered at, as returned by
46
+ # {DynamicImage::ImageSizing#available_width}
47
+ # @return [Array<Integer>]
48
+ # @raise [ArgumentError] if the spec isn't understood
49
+ def widths(available)
50
+ available = available.to_i
51
+ return [] unless available.positive?
52
+
53
+ case spec
54
+ when Range then stepped(available)
55
+ when Array then fixed(available)
56
+ when Integer then [spec]
57
+ else unknown_spec!
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def fixed(available)
64
+ widths = spec.map(&:to_i).select { _1 <= available }.uniq.sort
65
+ widths.any? ? widths : [available]
66
+ end
67
+
68
+ def stepped(available)
69
+ top = [available, spec.end || available].min.to_f
70
+ rest = Enumerator.produce(top / step) { _1 / step }
71
+ .take_while { _1 >= spec.begin }
72
+
73
+ [top, *rest].map { snap(_1) }.reverse.uniq
74
+ end
75
+
76
+ def snap(width)
77
+ [(width / SNAP).floor * SNAP, 1].max
78
+ end
79
+
80
+ def unknown_spec!
81
+ raise ArgumentError,
82
+ "breakpoints must be a Range, Array or Integer, " \
83
+ "got #{spec.class}"
84
+ end
85
+
86
+ def validate!
87
+ raise ArgumentError, "step must be greater than 1" unless step > 1
88
+ return unless spec.is_a?(Range) && spec.begin.nil?
89
+
90
+ raise ArgumentError, "breakpoints range must have a beginning"
91
+ end
92
+ end
93
+ end
@@ -3,14 +3,37 @@
3
3
  module DynamicImage
4
4
  # = DynamicImage Controller
5
5
  #
6
- # Generating images is rather expensive, so all requests must be
7
- # signed with a HMAC digest in order to avoid denial of service attacks.
8
- # The methods in +DynamicImage::Helper+ handles this transparently.
9
- # As a bonus, this also prevents unauthorized URL enumeration.
6
+ # Serves images. Include it in a controller and define a +model+ method returning the class to serve, then declare
7
+ # the routes with {DynamicImage::Routing#image_resources}.
8
+ #
9
+ # class ImagesController < ApplicationController
10
+ # include DynamicImage::Controller
11
+ #
12
+ # private
13
+ #
14
+ # def model
15
+ # Image
16
+ # end
17
+ # end
18
+ #
19
+ # Generating images is expensive, so every request must be signed with an HMAC digest. This protects against
20
+ # denial of service attacks and URL enumeration. The helpers in {DynamicImage::Helper} sign the URLs they build.
21
+ #
22
+ # Responses are cached for a year and answer +If-Modified-Since+. The URL carries a timestamp, so a changed image
23
+ # is a changed URL.
24
+ #
25
+ # @see DynamicImage::Helper
26
+ # @see DynamicImage::Routing
10
27
  module Controller
11
28
  extend ActiveSupport::Concern
12
29
  include Dis::Controller
13
30
 
31
+ # Formats +show+ and +uncropped+ will render.
32
+ PROCESSED_FORMATS = %i[gif jpeg jpg jxl png tiff webp].freeze
33
+
34
+ # Additional formats +original+ and +download+ will serve.
35
+ STORED_FORMATS = (PROCESSED_FORMATS + %i[avif bmp heic]).freeze
36
+
14
37
  included do
15
38
  before_action :verify_signed_params
16
39
  before_action :find_record
@@ -18,32 +41,50 @@ module DynamicImage
18
41
  helper_method :requested_size
19
42
  end
20
43
 
21
- # Renders the image.
44
+ # Renders the image, cropped and resized to the requested size.
45
+ #
46
+ # Responds to the image formats, and to HTML with a page showing the image and its metadata.
47
+ #
48
+ # @return [void]
22
49
  def show
23
50
  render_image(format: requested_format)
24
51
  end
25
52
 
26
53
  # Same as +show+, but renders the image without any pre-cropping applied.
54
+ #
55
+ # @return [void]
27
56
  def uncropped
28
57
  render_image(format: requested_format, uncropped: true)
29
58
  end
30
59
 
31
60
  # Renders the original image data, without any processing.
61
+ #
62
+ # @return [void]
32
63
  def original
33
64
  render_raw_image
34
65
  end
35
66
 
67
+ # Same as +original+, but served as an attachment so the browser downloads the file rather than displaying it.
68
+ #
69
+ # @return [void]
36
70
  def download
37
71
  render_raw_image(disposition: "attachment")
38
72
  end
39
73
 
40
74
  # Returns the requested size as a vector.
75
+ #
76
+ # @return [Vector2d] the size from the URL
41
77
  def requested_size
42
78
  Vector2d.parse(params[:size])
43
79
  end
44
80
 
45
81
  private
46
82
 
83
+ # Override in your controller to return the class being served. Required.
84
+ #
85
+ # @!method model
86
+ # @return [Class] the model class
87
+
47
88
  def cache_expiration_header
48
89
  return unless response.status == 200
49
90
 
@@ -51,10 +92,13 @@ module DynamicImage
51
92
  expires_in 1.year, public: true
52
93
  end
53
94
 
95
+ # Finds the record being served. Override to scope the lookup.
54
96
  def find_record
55
97
  @record = model.find(params[:id])
56
98
  end
57
99
 
100
+ # The filename the image is served as, the record's own filename with the extension of the rendered format.
101
+ # Override to name downloads something else.
58
102
  def filename(format = nil)
59
103
  if format.is_a?(DynamicImage::Format)
60
104
  File.basename(@record.filename, ".*") + format.extension
@@ -85,7 +129,7 @@ module DynamicImage
85
129
  render(template: "dynamic_image/images/show",
86
130
  layout: false, locals: { options: })
87
131
  end
88
- format.any(:gif, :jpeg, :jpg, :png, :tiff, :webp) do
132
+ format.any(*PROCESSED_FORMATS) do
89
133
  process_and_send(@record, options)
90
134
  end
91
135
  end
@@ -95,7 +139,7 @@ module DynamicImage
95
139
  return unless stale?(@record)
96
140
 
97
141
  respond_to do |format|
98
- format.any(:gif, :jpeg, :jpg, :png, :tiff, :webp) do
142
+ format.any(*STORED_FORMATS) do
99
143
  send_dis_data(@record,
100
144
  filename:,
101
145
  content_type: @record.content_type,
@@ -3,33 +3,44 @@
3
3
  module DynamicImage
4
4
  # = DynamicImage Digest Verifier
5
5
  #
6
- # ==== Usage
6
+ # Signs and verifies the digests embedded in image URLs. The engine sets up the instance the application uses and
7
+ # exposes it as <tt>DynamicImage.digest_verifier</tt>. The helpers sign with it and the controller verifies with
8
+ # it, so there is rarely a reason to use this directly.
7
9
  #
8
- # verifier = DynamicImage::DigestVerifier.new("super secret!")
10
+ # Adapted from +ActiveSupport::MessageVerifier+, without the handling for arbitrary data structures and without
11
+ # shipping the serialized data to the client.
9
12
  #
13
+ # @example
14
+ # verifier = DynamicImage::DigestVerifier.new("super secret!")
10
15
  # digest = verifier.generate("foo")
11
16
  #
12
- # digest.verify("foo", digest)
17
+ # verifier.verify("foo", digest)
13
18
  # # => true
14
- # digest.verify("bar", digest)
19
+ # verifier.verify("bar", digest)
15
20
  # # => raises DynamicImage::Errors::InvalidSignature
16
- #
17
- # Credit where credit is due: adapted and simplified from
18
- # +ActiveSupport::MessageVerifier+, since we don't need to handle
19
- # arbitrary data structures and ship the serialized data to the client.
20
21
  class DigestVerifier
22
+ # @param secret [String] the secret to sign with
23
+ # @param options [Hash]
24
+ # @option options [String] :digest the OpenSSL digest to use, defaults to "SHA1"
21
25
  def initialize(secret, options = {})
22
26
  @secret = secret
23
27
  @digest = options[:digest] || "SHA1"
24
28
  end
25
29
 
26
30
  # Generates a digest for a string.
31
+ #
32
+ # @param data [String] the string to sign
33
+ # @return [String] the hex digest
27
34
  def generate(data)
28
35
  generate_digest(data)
29
36
  end
30
37
 
31
38
  # Verifies that <tt>digest</tt> is valid for <tt>data</tt>.
32
- # Raises a +DynamicImage::Errors::InvalidSignature+ error if not.
39
+ #
40
+ # @param data [String] the signed string
41
+ # @param digest [String] the digest to check against
42
+ # @return [true] if the digest is valid
43
+ # @raise [DynamicImage::Errors::InvalidSignature] if it isn't
33
44
  def verify(data, digest)
34
45
  return true if valid_digest?(data, digest)
35
46
 
@@ -18,10 +18,13 @@ module DynamicImage
18
18
  end
19
19
 
20
20
  initializer "dynamic_image.mime_types" do
21
+ Mime::Type.register "image/avif", :avif
21
22
  Mime::Type.register "image/bmp", :bmp
22
23
  Mime::Type.register "image/gif", :gif
24
+ Mime::Type.register "image/heic", :heic, %w[image/heif]
23
25
  Mime::Type.register "image/jpeg", :jpg
24
26
  Mime::Type.register "image/jpeg", :jpeg
27
+ Mime::Type.register "image/jxl", :jxl
25
28
  Mime::Type.register "image/png", :png
26
29
  Mime::Type.register "image/tiff", :tiff
27
30
  Mime::Type.register "image/webp", :webp
@@ -1,19 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DynamicImage
4
+ # = DynamicImage Errors
5
+ #
6
+ # All errors raised by DynamicImage inherit from {DynamicImage::Errors::Error}, so a single rescue covers them all.
4
7
  module Errors
8
+ # Base class for all DynamicImage errors.
5
9
  class Error < StandardError; end
6
10
 
11
+ # Raised when processing is attempted on a record that isn't a valid image, either because the data is unreadable
12
+ # or because the record fails validation.
13
+ #
14
+ # @see DynamicImage::ProcessedImage#normalized
7
15
  class InvalidImage < DynamicImage::Errors::Error; end
8
16
 
17
+ # Raised when reading data that isn't in a format DynamicImage recognizes. The format is read from the header,
18
+ # not from any declared content type.
19
+ #
20
+ # @see DynamicImage::ImageReader#read
9
21
  class InvalidHeader < DynamicImage::Errors::Error; end
10
22
 
23
+ # Raised when the digest in a request doesn't match the parameters. Rescued as <tt>:unauthorized</tt>, so
24
+ # Rails renders it as 401.
25
+ #
26
+ # Expired and hand-edited URLs both produce this.
27
+ #
28
+ # @see DynamicImage::DigestVerifier#verify
11
29
  class InvalidSignature < DynamicImage::Errors::Error; end
12
30
 
31
+ # Raised when cropping is requested without both dimensions, as in <tt>size: "400x", crop: true</tt>. There is no
32
+ # way to crop to an exact size when one of them is unknown.
33
+ #
34
+ # @see DynamicImage::ImageSizing#fit
13
35
  class InvalidSizeOptions < DynamicImage::Errors::Error; end
14
36
 
37
+ # Raised when a transformation can't be applied, either because a rotation isn't a multiple of 90 degrees or
38
+ # because a crop falls outside the image.
39
+ #
40
+ # @see DynamicImage::ImageProcessor::Transform
15
41
  class InvalidTransformation < DynamicImage::Errors::Error; end
16
42
 
43
+ # Raised when a request is missing a parameter needed to verify the signature. Like {InvalidSignature}, this is
44
+ # what a malformed URL produces.
45
+ #
46
+ # @see DynamicImage::Controller
17
47
  class ParameterMissing < DynamicImage::Errors::Error; end
18
48
  end
19
49
  end
@@ -1,71 +1,170 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DynamicImage
4
+ # = DynamicImage Format
5
+ #
6
+ # A registry of the image formats DynamicImage understands. Each format knows its content types, extensions, the
7
+ # magic bytes that identify it, the options it is saved with, and whether it can hold more than one frame.
8
+ #
9
+ # Formats are looked up by name, by content type, or by sniffing the first bytes of a file. Uploads are always
10
+ # identified by sniffing, never by the content type the client claims.
11
+ #
12
+ # @example
13
+ # DynamicImage::Format.find("jpg") # => the JPEG format
14
+ # DynamicImage::Format.content_type("image/png")
15
+ # DynamicImage::Format.sniff(File.binread(path, 32))
4
16
  class Format
5
- attr_reader :name, :animated, :content_types, :extensions, :magic_bytes,
6
- :save_options, :signature
17
+ # @!attribute [r] name
18
+ # @return [String] the format name, such as "JPEG"
19
+ # @!attribute [r] animated
20
+ # @return [Boolean] whether the format holds more than one frame
21
+ # @!attribute [r] alpha
22
+ # @return [Boolean] whether the format holds an alpha channel
23
+ # @!attribute [r] content_types
24
+ # @return [Array<String>] the content types, canonical one first
25
+ # @!attribute [r] extensions
26
+ # @return [Array<String>] the file extensions, preferred one first
27
+ # @!attribute [r] magic_bytes
28
+ # @return [Array<String>] byte sequences identifying the format
29
+ # @!attribute [r] offset
30
+ # @return [Integer] where in the header the magic bytes sit
31
+ # @!attribute [r] save_options
32
+ # @return [Hash] options passed to vips when writing
33
+ # @!attribute [r] signature
34
+ # @return [Proc, nil] an extra check against the header, for when
35
+ # the magic bytes alone are ambiguous
36
+ attr_reader :name, :animated, :alpha, :content_types, :extensions,
37
+ :magic_bytes, :offset, :save_options, :signature
7
38
 
39
+ # @param name [String] the format name
40
+ # @param options [Hash] the format definition, as passed to {Format.register}
41
+ # @see Format.register
8
42
  def initialize(name, options)
9
43
  options = default_options.merge(options)
10
44
 
11
45
  @name = name
12
46
  @animated = options[:animated]
47
+ @alpha = options[:alpha]
13
48
  @content_types = Array(options[:content_type])
14
49
  @extensions = Array(options[:extension])
15
- @magic_bytes = options[:magic_bytes].map do |s|
16
- s.dup.force_encoding("binary")
17
- end
50
+ @magic_bytes = options[:magic_bytes].map(&:b)
51
+ @offset = options[:offset]
18
52
  @signature = options[:signature]
19
53
  @save_options = options[:save_options]
20
54
  end
21
55
 
56
+ # Returns true if the format supports multiple frames.
57
+ #
58
+ # @return [Boolean]
22
59
  def animated?
23
60
  animated
24
61
  end
25
62
 
63
+ # Returns true if the format supports an alpha channel.
64
+ #
65
+ # @return [Boolean]
66
+ def alpha?
67
+ alpha
68
+ end
69
+
70
+ # Returns true if the given header belongs to this format.
71
+ #
72
+ # @param bytes [String] the first bytes of the file
73
+ # @return [Boolean]
26
74
  def matches?(bytes)
27
- return false unless magic_bytes.any? { |b| bytes.start_with?(b) }
75
+ header = bytes.to_s[offset..].to_s
76
+ return false unless magic_bytes.any? { |b| header.start_with?(b) }
28
77
 
29
78
  signature.nil? || signature.call(bytes)
30
79
  end
31
80
 
81
+ # The canonical content type.
82
+ #
83
+ # @return [String]
32
84
  def content_type
33
85
  content_types.first
34
86
  end
35
87
 
88
+ # The preferred file extension, leading dot included.
89
+ #
90
+ # @return [String]
36
91
  def extension
37
92
  extensions.first
38
93
  end
39
94
 
95
+ # The canonical content type as a Mime::Type.
96
+ #
97
+ # @return [Mime::Type]
98
+ def mime_type
99
+ Mime::Type.lookup(content_type)
100
+ end
101
+
40
102
  class << self
103
+ # Finds the format for a content type.
104
+ #
105
+ # @param type [String] the content type
106
+ # @return [DynamicImage::Format, nil]
41
107
  def content_type(type)
42
108
  formats.filter { |f| f.content_types.include?(type) }.first
43
109
  end
44
110
 
111
+ # Every content type of every registered format.
112
+ #
113
+ # @return [Array<String>]
45
114
  def content_types
46
115
  formats.flat_map(&:content_types)
47
116
  end
48
117
 
118
+ # Finds a format by name. Case insensitive, and "JPG" is understood as an alias for "JPEG".
119
+ #
120
+ # @param name [String, Symbol] the format name
121
+ # @return [DynamicImage::Format, nil]
49
122
  def find(name)
50
123
  key = name.to_s.upcase
51
124
  key = "JPEG" if key == "JPG"
52
125
  registered_formats[key]
53
126
  end
54
127
 
128
+ # All registered formats.
129
+ #
130
+ # @return [Array<DynamicImage::Format>]
55
131
  def formats
56
132
  registered_formats.map { |_, f| f }
57
133
  end
58
134
 
135
+ # Registers a format.
136
+ #
137
+ # Each option sets the attribute of the same name, except +content_type+ and +extension+, which are singular
138
+ # here and accept either one value or a list. Anything left out falls back to {Format#default_options}.
139
+ #
140
+ # @param name [String] the format name, uppercase by convention
141
+ # @param opts [Hash] the format definition
142
+ # @return [DynamicImage::Format] the registered format
59
143
  def register(name, **opts)
60
144
  registered_formats[name] = new(name, opts)
61
145
  end
62
146
 
147
+ # Identifies a format from the first bytes of a file.
148
+ #
149
+ # @param bytes [String, nil] the file header
150
+ # @return [DynamicImage::Format, nil] the format, if recognized
63
151
  def sniff(bytes)
64
152
  return unless bytes
65
153
 
66
154
  formats.find { |format| format.matches?(bytes) }
67
155
  end
68
156
 
157
+ # The brands declared by an ISO base media file, major brand first, followed by the compatible brands. Empty for
158
+ # anything that isn't an +ftyp+ box.
159
+ #
160
+ # @param bytes [String] the file header
161
+ # @return [Array<String>]
162
+ def iso_brands(bytes)
163
+ return [] unless bytes.to_s.bytesize >= 12 && bytes[4, 4] == "ftyp".b
164
+
165
+ [bytes[8, 4]] + bytes[16...bytes.unpack1("N")].to_s.scan(/.{4}/m)
166
+ end
167
+
69
168
  private
70
169
 
71
170
  def registered_formats
@@ -73,11 +172,25 @@ module DynamicImage
73
172
  end
74
173
  end
75
174
 
175
+ # Defaults every format definition is merged over.
176
+ #
177
+ # @return [Hash]
76
178
  def default_options
77
- { animated: false, content_type: [], extension: [], magic_bytes: [],
78
- signature: nil, save_options: {} }
179
+ { animated: false, alpha: false, content_type: [], extension: [],
180
+ magic_bytes: [], offset: 0, signature: nil, save_options: {} }
79
181
  end
80
182
 
183
+ register(
184
+ "AVIF",
185
+ animated: true,
186
+ alpha: true,
187
+ content_type: %w[image/avif],
188
+ extension: %w[.avif],
189
+ offset: 4,
190
+ magic_bytes: %w[ftyp],
191
+ signature: ->(bytes) { iso_brands(bytes).intersect?(%w[avif avis]) }
192
+ )
193
+
81
194
  register(
82
195
  "BMP",
83
196
  content_type: %w[image/bmp],
@@ -88,11 +201,27 @@ module DynamicImage
88
201
  register(
89
202
  "GIF",
90
203
  animated: true,
204
+ alpha: true,
91
205
  content_type: %w[image/gif],
92
206
  extension: %w[.gif],
93
207
  magic_bytes: %w[GIF87a GIF89a]
94
208
  )
95
209
 
210
+ register(
211
+ "HEIC",
212
+ animated: true,
213
+ alpha: true,
214
+ content_type: %w[image/heic image/heif],
215
+ extension: %w[.heic .heif],
216
+ offset: 4,
217
+ magic_bytes: %w[ftyp],
218
+ signature: lambda { |bytes|
219
+ iso_brands(bytes).intersect?(
220
+ %w[heic heix hevc hevx heim heis hevm hevs mif1 msf1]
221
+ )
222
+ }
223
+ )
224
+
96
225
  register(
97
226
  "JPEG",
98
227
  content_type: %w[image/jpeg image/pjpeg],
@@ -101,8 +230,18 @@ module DynamicImage
101
230
  save_options: { Q: 90, strip: true, background: [255.0, 255.0, 255.0] }
102
231
  )
103
232
 
233
+ register(
234
+ "JXL",
235
+ alpha: true,
236
+ content_type: %w[image/jxl],
237
+ extension: %w[.jxl],
238
+ magic_bytes: ["\xff\x0a", "\x00\x00\x00\x0cJXL \r\n\x87\n"],
239
+ save_options: { Q: 75, effort: 3, strip: true }
240
+ )
241
+
104
242
  register(
105
243
  "PNG",
244
+ alpha: true,
106
245
  content_type: %w[image/png],
107
246
  extension: %w[.png],
108
247
  magic_bytes: ["\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"]
@@ -110,6 +249,7 @@ module DynamicImage
110
249
 
111
250
  register(
112
251
  "TIFF",
252
+ alpha: true,
113
253
  content_type: %w[image/tiff],
114
254
  extension: %w[.tiff .tif],
115
255
  magic_bytes: ["\x49\x49\x2a\x00", "\x4d\x4d\x00\x2a"]
@@ -118,6 +258,7 @@ module DynamicImage
118
258
  register(
119
259
  "WEBP",
120
260
  animated: true,
261
+ alpha: true,
121
262
  content_type: %w[image/webp],
122
263
  extension: %w[.webp],
123
264
  magic_bytes: ["\x52\x49\x46\x46"],
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DynamicImage
4
+ # = DynamicImage Format Negotiator
5
+ #
6
+ # Picks the format to render an image in, given the formats the consumer accepts.
7
+ #
8
+ # An acceptable source format is used as-is. Otherwise the image is transcoded to the candidate that keeps the
9
+ # most of it: animation is preserved before transparency, and a constraint no candidate can satisfy is dropped
10
+ # instead of vetoing the next one.
11
+ #
12
+ # Selection is keyed on the properties the image has, not on what its format is capable of. A photograph uploaded
13
+ # as WebP renders as JPEG, not as a 256 colour GIF.
14
+ #
15
+ # Images stored before +frame_count+ and +alpha+ existed have neither, and an unacceptable source format resolves
16
+ # to JPEG.
17
+ #
18
+ # @example
19
+ # DynamicImage::FormatNegotiator.new(image).negotiate(%i[jpeg png gif])
20
+ # # => the PNG format
21
+ #
22
+ # @see DynamicImage::Format
23
+ class FormatNegotiator
24
+ FALLBACK = "JPEG"
25
+
26
+ # @!attribute [r] record
27
+ # @return [DynamicImage::Model]
28
+ attr_reader :record
29
+
30
+ # @param record [DynamicImage::Model] the image
31
+ def initialize(record)
32
+ @record = record
33
+ end
34
+
35
+ # Returns the format to render in.
36
+ #
37
+ # @param accepted [Array<Symbol, String>] the acceptable format names, most preferred first. Unrecognized names
38
+ # are ignored.
39
+ # @return [DynamicImage::Format]
40
+ def negotiate(accepted)
41
+ candidates = Array(accepted).filter_map { |name| Format.find(name) }
42
+ source(candidates) || transcode(candidates) || Format.find(FALLBACK)
43
+ end
44
+
45
+ private
46
+
47
+ def source(candidates)
48
+ candidates.find { |f| f.content_types.include?(record.content_type) }
49
+ end
50
+
51
+ def transcode(candidates)
52
+ return unless known?
53
+
54
+ candidates = narrow(candidates, :animated?) if animated?
55
+ candidates = narrow(candidates, :alpha?) if alpha?
56
+ candidates.first
57
+ end
58
+
59
+ def narrow(candidates, capability)
60
+ supported = candidates.select(&capability)
61
+ supported.any? ? supported : candidates
62
+ end
63
+
64
+ def frame_count
65
+ record.frame_count if record.has_attribute?(:frame_count)
66
+ end
67
+
68
+ def alpha
69
+ record.alpha if record.has_attribute?(:alpha)
70
+ end
71
+
72
+ def known?
73
+ !frame_count.nil? && !alpha.nil?
74
+ end
75
+
76
+ def animated?
77
+ frame_count.to_i > 1
78
+ end
79
+
80
+ def alpha?
81
+ alpha ? true : false
82
+ end
83
+ end
84
+ end