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.
- checksums.yaml +4 -4
- data/README.md +393 -67
- 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 +51 -7
- data/lib/dynamic_image/digest_verifier.rb +20 -9
- data/lib/dynamic_image/engine.rb +3 -0
- 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 +47 -11
- data/lib/dynamic_image/model/validations.rb +15 -8
- data/lib/dynamic_image/model/variants.rb +6 -3
- data/lib/dynamic_image/model.rb +58 -34
- 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 +48 -14
- 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 +23 -12
- data/lib/dynamic_image/jobs/create_variant.rb +0 -22
- 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
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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(
|
|
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(
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
17
|
+
# verifier.verify("foo", digest)
|
|
13
18
|
# # => true
|
|
14
|
-
#
|
|
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
|
-
#
|
|
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
|
|
data/lib/dynamic_image/engine.rb
CHANGED
|
@@ -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
|
data/lib/dynamic_image/errors.rb
CHANGED
|
@@ -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
|
data/lib/dynamic_image/format.rb
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
|
16
|
-
|
|
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
|
-
|
|
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,
|
|
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
|