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
|
@@ -7,10 +7,17 @@ require "dynamic_image/image_processor/transform"
|
|
|
7
7
|
module DynamicImage
|
|
8
8
|
# = ImageProcessor
|
|
9
9
|
#
|
|
10
|
-
#
|
|
10
|
+
# The image processing pipeline.
|
|
11
11
|
#
|
|
12
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -3,26 +3,37 @@
|
|
|
3
3
|
module DynamicImage
|
|
4
4
|
# = DynamicImage Image Sizing
|
|
5
5
|
#
|
|
6
|
-
# Calculates cropping and fitting for image sizes.
|
|
6
|
+
# Calculates cropping and fitting for image sizes. The helpers use it to work out the dimensions of a rendered
|
|
7
|
+
# image, and it can be used on its own when you need the size without rendering anything: reserving space in a
|
|
8
|
+
# layout, filling in <tt>og:image:width</tt>, or laying out a PDF.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# sizing = DynamicImage::ImageSizing.new(image)
|
|
12
|
+
# sizing.fit("400x400") # => Vector2d(400.0, 250.0)
|
|
7
13
|
class ImageSizing
|
|
14
|
+
# @param record [DynamicImage::Model] the image
|
|
15
|
+
# @param options [Hash]
|
|
16
|
+
# @option options [Boolean] :uncropped Ignore any crop stored on the record and size against the original image
|
|
8
17
|
def initialize(record, options = {})
|
|
9
18
|
@record = record
|
|
10
19
|
@uncropped = options[:uncropped] ? true : false
|
|
11
20
|
end
|
|
12
21
|
|
|
13
|
-
# Calculates crop geometry. The given vector is scaled
|
|
14
|
-
#
|
|
15
|
-
#
|
|
22
|
+
# Calculates crop geometry. The given vector is scaled to match the image size, since cropping happens before
|
|
23
|
+
# resizing.
|
|
24
|
+
#
|
|
25
|
+
# The crop is positioned to keep the record's crop gravity as close to the center as possible, clamped to the
|
|
26
|
+
# bounds of the image.
|
|
16
27
|
#
|
|
17
|
-
#
|
|
28
|
+
# @param ratio_vector [Vector2d] the aspect ratio to crop to
|
|
29
|
+
# @return [Array(Vector2d, Vector2d)] the crop size and crop start
|
|
18
30
|
#
|
|
31
|
+
# @example
|
|
19
32
|
# image = Image.find(params[:id]) # 320x200 image
|
|
20
33
|
# sizing = DynamicImage::ImageSizing.new(image)
|
|
21
34
|
#
|
|
22
35
|
# sizing.crop_geometry(Vector2d(100, 100))
|
|
23
36
|
# # => [Vector2d(200, 200), Vector2d(60, 0)]
|
|
24
|
-
#
|
|
25
|
-
# Returns a tuple with crop size and crop start vectors.
|
|
26
37
|
def crop_geometry(ratio_vector)
|
|
27
38
|
# Maximize the crop area to fit the image size
|
|
28
39
|
crop_size = ratio_vector.fit(size).round
|
|
@@ -36,18 +47,41 @@ module DynamicImage
|
|
|
36
47
|
[crop_size, (start + crop_start)]
|
|
37
48
|
end
|
|
38
49
|
|
|
39
|
-
#
|
|
40
|
-
#
|
|
50
|
+
# Returns the widest the image can be rendered at, in pixels.
|
|
51
|
+
#
|
|
52
|
+
# Without a ratio this is the image's own width. With one it is the width of the largest crop matching that
|
|
53
|
+
# ratio.
|
|
54
|
+
#
|
|
55
|
+
# @param ratio [Numeric, Vector2d, String, nil] the aspect ratio, in any form {DynamicImage::Ratio} understands
|
|
56
|
+
# @return [Integer]
|
|
41
57
|
#
|
|
42
|
-
#
|
|
58
|
+
# @example
|
|
59
|
+
# image = Image.find(params[:id]) # 320x200 image
|
|
60
|
+
# sizing = DynamicImage::ImageSizing.new(image)
|
|
43
61
|
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
|
|
62
|
+
# sizing.available_width # => 320
|
|
63
|
+
# sizing.available_width(16.0 / 9) # => 320
|
|
64
|
+
# sizing.available_width(9.0 / 16) # => 113
|
|
65
|
+
def available_width(ratio = nil)
|
|
66
|
+
ratio = DynamicImage::Ratio.parse(ratio)
|
|
67
|
+
return size.x.floor unless ratio
|
|
68
|
+
|
|
69
|
+
crop_geometry(vector(ratio, 1)).first.x.floor
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Adjusts +fit_size+ to fit the image dimensions. Any dimension set to zero will be ignored.
|
|
48
73
|
#
|
|
49
|
-
#
|
|
74
|
+
# @param fit_size [Vector2d, String] the size to fit within, either a vector or a <tt>"{width}x{height}"</tt>
|
|
75
|
+
# string. Either dimension may be omitted for a fixed width or height.
|
|
76
|
+
# @param options [Hash]
|
|
77
|
+
# @option options [Boolean] :crop Don't keep aspect ratio. This will allow the image to be cropped to the
|
|
78
|
+
# requested size.
|
|
79
|
+
# @option options [Boolean] :upscale Don't limit to the size of the image. Images smaller than the given size will
|
|
80
|
+
# be scaled up.
|
|
81
|
+
# @return [Vector2d] the resulting size
|
|
82
|
+
# @raise [DynamicImage::Errors::InvalidSizeOptions] if <tt>crop: true</tt> is given and either dimension is zero
|
|
50
83
|
#
|
|
84
|
+
# @example
|
|
51
85
|
# image = Image.find(params[:id]) # 320x200 image
|
|
52
86
|
# sizing = DynamicImage::ImageSizing.new(image)
|
|
53
87
|
#
|
|
@@ -62,7 +96,6 @@ module DynamicImage
|
|
|
62
96
|
#
|
|
63
97
|
# sizing.fit(Vector2d(500, 500), upscale: true)
|
|
64
98
|
# # => Vector2d(500.0, 312.5)
|
|
65
|
-
#
|
|
66
99
|
def fit(fit_size, options = {})
|
|
67
100
|
fit_size = parse_vector(fit_size)
|
|
68
101
|
require_dimensions!(fit_size) if options[:crop]
|
|
@@ -97,9 +130,8 @@ module DynamicImage
|
|
|
97
130
|
end
|
|
98
131
|
end
|
|
99
132
|
|
|
100
|
-
# Clamps the rectangle defined by +start+ and +size+
|
|
101
|
-
#
|
|
102
|
-
# that +size+ will always be smaller than +max_size+.
|
|
133
|
+
# Clamps the rectangle defined by +start+ and +size+ to fit inside 0, 0 and +max_size+. It is assumed that +size+
|
|
134
|
+
# will always be smaller than +max_size+.
|
|
103
135
|
#
|
|
104
136
|
# Returns the start vector.
|
|
105
137
|
def clamp(start, size, max_size)
|
|
@@ -4,13 +4,24 @@ module DynamicImage
|
|
|
4
4
|
# = DynamicImage Metadata
|
|
5
5
|
#
|
|
6
6
|
# Parses metadata from an image. Accepts a Pathname, IO, or binary string.
|
|
7
|
+
#
|
|
8
|
+
# Every reader returns nil for data that isn't a readable image.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# metadata = DynamicImage::Metadata.new(Pathname.new("image.jpg"))
|
|
12
|
+
# metadata.valid? # => true
|
|
13
|
+
# metadata.content_type # => "image/jpeg"
|
|
14
|
+
# metadata.dimensions # => Vector2d(320, 200)
|
|
7
15
|
class Metadata
|
|
16
|
+
# @param data [Pathname, IO, String] the image, as a path, an open file or a binary string
|
|
8
17
|
def initialize(data)
|
|
9
18
|
@data = data
|
|
10
19
|
end
|
|
11
20
|
|
|
12
|
-
# Returns the color space of the image as a string. The result will be one
|
|
13
|
-
#
|
|
21
|
+
# Returns the color space of the image as a string. The result will be one of the following: "rgb", "cmyk",
|
|
22
|
+
# "gray".
|
|
23
|
+
#
|
|
24
|
+
# @return [String, nil]
|
|
14
25
|
def colorspace
|
|
15
26
|
return unless valid?
|
|
16
27
|
|
|
@@ -25,30 +36,61 @@ module DynamicImage
|
|
|
25
36
|
end
|
|
26
37
|
|
|
27
38
|
# Returns the content type of the image.
|
|
39
|
+
#
|
|
40
|
+
# @return [String, nil]
|
|
28
41
|
def content_type
|
|
29
42
|
reader.format.content_type if valid?
|
|
30
43
|
end
|
|
31
44
|
|
|
45
|
+
# Returns the name of the detected format.
|
|
46
|
+
#
|
|
47
|
+
# @return [String, nil] the format name, such as "JPEG"
|
|
32
48
|
def format
|
|
33
49
|
reader.format.name if valid?
|
|
34
50
|
end
|
|
35
51
|
|
|
36
|
-
# Returns the dimensions of the image as a vector.
|
|
52
|
+
# Returns the dimensions of the image as a vector. EXIF rotation is applied, so these are the dimensions the
|
|
53
|
+
# image has once normalized.
|
|
54
|
+
#
|
|
55
|
+
# @return [Vector2d, nil]
|
|
37
56
|
def dimensions
|
|
38
57
|
Vector2d.new(metadata[:width], metadata[:height]) if valid?
|
|
39
58
|
end
|
|
40
59
|
|
|
41
60
|
# Returns the width of the image.
|
|
61
|
+
#
|
|
62
|
+
# @return [Integer, nil] the width in pixels
|
|
42
63
|
def width
|
|
43
64
|
metadata[:width] if valid?
|
|
44
65
|
end
|
|
45
66
|
|
|
46
67
|
# Returns the height of the image.
|
|
68
|
+
#
|
|
69
|
+
# @return [Integer, nil] the height in pixels
|
|
47
70
|
def height
|
|
48
71
|
metadata[:height] if valid?
|
|
49
72
|
end
|
|
50
73
|
|
|
51
|
-
# Returns
|
|
74
|
+
# Returns the number of frames. Animated formats can have more than one; everything else has a single frame.
|
|
75
|
+
#
|
|
76
|
+
# @return [Integer, nil] the number of frames
|
|
77
|
+
def frame_count
|
|
78
|
+
metadata[:frame_count] if valid?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Returns true if the image has an alpha channel.
|
|
82
|
+
#
|
|
83
|
+
# This is the presence of the channel, not of actual transparency: an image can carry a fully opaque alpha
|
|
84
|
+
# channel. Reading it costs nothing, where scanning the channel for transparency would mean decoding every pixel.
|
|
85
|
+
#
|
|
86
|
+
# @return [Boolean, nil] true if the image has an alpha channel
|
|
87
|
+
def alpha?
|
|
88
|
+
metadata[:alpha] if valid?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Returns true if the data is a readable image in a supported format.
|
|
92
|
+
#
|
|
93
|
+
# @return [Boolean]
|
|
52
94
|
def valid?
|
|
53
95
|
@data && reader.valid_header? && metadata != :invalid
|
|
54
96
|
end
|
|
@@ -67,11 +109,20 @@ module DynamicImage
|
|
|
67
109
|
image = reader.read
|
|
68
110
|
width, height = dimensions_from(image)
|
|
69
111
|
width, height = height, width if rotated?(image)
|
|
70
|
-
{ width:, height:,
|
|
112
|
+
{ width:, height:,
|
|
113
|
+
colorspace: image.get("interpretation"),
|
|
114
|
+
frame_count: frame_count_from(image),
|
|
115
|
+
alpha: image.has_alpha? }
|
|
71
116
|
rescue Vips::Error
|
|
72
117
|
:invalid
|
|
73
118
|
end
|
|
74
119
|
|
|
120
|
+
def frame_count_from(image)
|
|
121
|
+
return 1 unless image.get_fields.include?("n-pages")
|
|
122
|
+
|
|
123
|
+
image.get("n-pages")
|
|
124
|
+
end
|
|
125
|
+
|
|
75
126
|
def dimensions_from(image)
|
|
76
127
|
width = image.get("width")
|
|
77
128
|
height = if image.get_fields.include?("page-height")
|
|
@@ -4,17 +4,17 @@ module DynamicImage
|
|
|
4
4
|
module Model
|
|
5
5
|
# = DynamicImage Model Dimensions
|
|
6
6
|
#
|
|
7
|
+
# Vector accessors over the +real_*+ and +crop_*+ columns.
|
|
8
|
+
#
|
|
9
|
+
# +real_size+ is the size of the stored image. +size+ is the size after cropping, or +real_size+ when no crop
|
|
10
|
+
# is set.
|
|
7
11
|
module Dimensions
|
|
8
|
-
# Returns the crop gravity.
|
|
9
|
-
#
|
|
10
|
-
# DynamicImage will try to keep the pixel represented by
|
|
11
|
-
# crop_gravity as close to the center as possible when cropping
|
|
12
|
-
# images.
|
|
12
|
+
# Returns the crop gravity, the focal point kept as close to the center as possible when cropping.
|
|
13
13
|
#
|
|
14
|
-
#
|
|
14
|
+
# The coordinates are relative to 0,0 on the original image. Unless set explicitly, the gravity is the center
|
|
15
|
+
# of the cropped image.
|
|
15
16
|
#
|
|
16
|
-
#
|
|
17
|
-
# the center of the cropped image.
|
|
17
|
+
# @return [Vector2d, nil]
|
|
18
18
|
def crop_gravity
|
|
19
19
|
if crop_gravity?
|
|
20
20
|
vector(crop_gravity_x, crop_gravity_y)
|
|
@@ -25,22 +25,30 @@ module DynamicImage
|
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
# Returns true if crop gravity has been
|
|
28
|
+
# Returns true if the crop gravity has been set explicitly.
|
|
29
|
+
#
|
|
30
|
+
# @return [Boolean]
|
|
29
31
|
def crop_gravity?
|
|
30
32
|
crop_gravity_x.present? && crop_gravity_y.present?
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
# Returns the crop size, or nil if no cropping is applied.
|
|
36
|
+
#
|
|
37
|
+
# @return [Vector2d, nil]
|
|
34
38
|
def crop_size
|
|
35
39
|
vector(crop_width, crop_height) if crop_size?
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
# Returns true if crop size has been set.
|
|
43
|
+
#
|
|
44
|
+
# @return [Boolean]
|
|
39
45
|
def crop_size?
|
|
40
46
|
crop_width? && crop_height?
|
|
41
47
|
end
|
|
42
48
|
|
|
43
49
|
# Returns the crop start if set, or Vector2d(0, 0) if not.
|
|
50
|
+
#
|
|
51
|
+
# @return [Vector2d] the top left corner of the crop
|
|
44
52
|
def crop_start
|
|
45
53
|
if crop_start?
|
|
46
54
|
vector(crop_start_x, crop_start_y)
|
|
@@ -50,32 +58,43 @@ module DynamicImage
|
|
|
50
58
|
end
|
|
51
59
|
|
|
52
60
|
# Returns true if crop start has been set.
|
|
61
|
+
#
|
|
62
|
+
# @return [Boolean]
|
|
53
63
|
def crop_start?
|
|
54
64
|
crop_start_x.present? && crop_start_y.present?
|
|
55
65
|
end
|
|
56
66
|
|
|
57
67
|
# Returns true if the image is cropped.
|
|
68
|
+
#
|
|
69
|
+
# @return [Boolean]
|
|
58
70
|
def cropped?
|
|
59
71
|
crop_size? && real_size? && crop_size != real_size
|
|
60
72
|
end
|
|
61
73
|
|
|
62
74
|
# Returns the real size of the image, without any cropping applied.
|
|
75
|
+
#
|
|
76
|
+
# @return [Vector2d, nil] the size of the stored image
|
|
63
77
|
def real_size
|
|
64
78
|
vector(real_width, real_height) if real_size?
|
|
65
79
|
end
|
|
66
80
|
|
|
67
|
-
# Returns true if the size has been set.
|
|
81
|
+
# Returns true if the real size has been set.
|
|
82
|
+
#
|
|
83
|
+
# @return [Boolean]
|
|
68
84
|
def real_size?
|
|
69
85
|
real_width? && real_height?
|
|
70
86
|
end
|
|
71
87
|
|
|
72
|
-
# Returns the cropped size if the image
|
|
73
|
-
#
|
|
88
|
+
# Returns the cropped size, or the real size if the image isn't cropped.
|
|
89
|
+
#
|
|
90
|
+
# @return [Vector2d, nil] the visible size of the image
|
|
74
91
|
def size
|
|
75
92
|
crop_size || real_size
|
|
76
93
|
end
|
|
77
94
|
|
|
78
|
-
# Returns true if the image has size
|
|
95
|
+
# Returns true if the image has a size.
|
|
96
|
+
#
|
|
97
|
+
# @return [Boolean]
|
|
79
98
|
def size?
|
|
80
99
|
size ? true : false
|
|
81
100
|
end
|
|
@@ -4,20 +4,32 @@ module DynamicImage
|
|
|
4
4
|
module Model
|
|
5
5
|
# = DynamicImage Model Transformations
|
|
6
6
|
#
|
|
7
|
+
# Transformations that rewrite the stored file. Per-request processing never touches it.
|
|
8
|
+
#
|
|
9
|
+
# Both methods replace the data and update the stored dimensions, adjusting the crop to match. Neither saves
|
|
10
|
+
# the record.
|
|
7
11
|
module Transformations
|
|
8
|
-
# Resizes the image
|
|
12
|
+
# Resizes the image, replacing the stored file with a smaller one. The crop is scaled along with it.
|
|
13
|
+
#
|
|
14
|
+
# @param max_size [Vector2d] the size to scale down to
|
|
15
|
+
# @return [self]
|
|
9
16
|
def resize(max_size)
|
|
10
17
|
transform_image do |image|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
self[attr] = self[attr] * scale if self[attr]
|
|
15
|
-
end
|
|
16
|
-
image.resize(new_size)
|
|
18
|
+
resized = image.resize(real_size.constrain_both(max_size))
|
|
19
|
+
scale_crop(resized.size)
|
|
20
|
+
resized
|
|
17
21
|
end
|
|
18
22
|
end
|
|
19
23
|
|
|
20
|
-
# Rotates the image
|
|
24
|
+
# Rotates the image, taking the crop and crop gravity with it.
|
|
25
|
+
#
|
|
26
|
+
# @param degrees [Integer] the angle, which must be a multiple of 90. Rotating by 0 is a no-op.
|
|
27
|
+
# @return [self]
|
|
28
|
+
# @raise [DynamicImage::Errors::InvalidTransformation] if the angle isn't a multiple of 90
|
|
29
|
+
#
|
|
30
|
+
# @example
|
|
31
|
+
# image.rotate(90)
|
|
32
|
+
# image.save
|
|
21
33
|
def rotate(degrees = 90)
|
|
22
34
|
degrees = degrees.to_i % 360
|
|
23
35
|
|
|
@@ -36,9 +48,33 @@ module DynamicImage
|
|
|
36
48
|
|
|
37
49
|
private
|
|
38
50
|
|
|
39
|
-
def
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
def scale_crop(new_size)
|
|
52
|
+
scale = new_size.to_f_vector / real_size
|
|
53
|
+
|
|
54
|
+
scale_crop_start(scale, new_size)
|
|
55
|
+
scale_crop_size(scale, new_size)
|
|
56
|
+
scale_crop_gravity(scale, new_size)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def scale_crop_start(scale, new_size)
|
|
60
|
+
return unless crop_start?
|
|
61
|
+
|
|
62
|
+
self.crop_start_x, self.crop_start_y =
|
|
63
|
+
(crop_start * scale).floor.clamp(0, new_size - 1).to_a
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def scale_crop_size(scale, new_size)
|
|
67
|
+
return unless crop_size?
|
|
68
|
+
|
|
69
|
+
self.crop_width, self.crop_height =
|
|
70
|
+
(crop_size * scale).round.clamp(1, new_size - crop_start).to_a
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def scale_crop_gravity(scale, new_size)
|
|
74
|
+
return unless crop_gravity?
|
|
75
|
+
|
|
76
|
+
self.crop_gravity_x, self.crop_gravity_y =
|
|
77
|
+
(crop_gravity * scale).round.clamp(1, new_size).to_a
|
|
42
78
|
end
|
|
43
79
|
|
|
44
80
|
def rotate_dimensions(width, height, degrees)
|
|
@@ -76,7 +112,9 @@ module DynamicImage
|
|
|
76
112
|
|
|
77
113
|
def transform_image(&block)
|
|
78
114
|
read_image_metadata if data_changed?
|
|
79
|
-
self.data =
|
|
115
|
+
self.data = with_data_file do |path|
|
|
116
|
+
block.call(DynamicImage::ImageProcessor.new(path)).read
|
|
117
|
+
end
|
|
80
118
|
read_image_metadata
|
|
81
119
|
self
|
|
82
120
|
end
|
|
@@ -4,9 +4,10 @@ module DynamicImage
|
|
|
4
4
|
module Model
|
|
5
5
|
# = DynamicImage Model Validations
|
|
6
6
|
#
|
|
7
|
-
# Validates
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# Validates the image attributes. +DynamicImage::Model+ manages all of them, so these mostly enforce integrity.
|
|
8
|
+
#
|
|
9
|
+
# The two that can fail on ordinary input are the image itself, which is rejected if the data isn't readable in
|
|
10
|
+
# a supported format, and the crop, which has to fit within the image.
|
|
10
11
|
module Validations
|
|
11
12
|
extend ActiveSupport::Concern
|
|
12
13
|
|
|
@@ -30,9 +31,7 @@ module DynamicImage
|
|
|
30
31
|
length: { maximum: 255 }
|
|
31
32
|
|
|
32
33
|
validates :real_width, :real_height,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
validates :real_width, :real_height,
|
|
34
|
+
presence: true,
|
|
36
35
|
numericality: { greater_than: 0, only_integer: true }
|
|
37
36
|
|
|
38
37
|
validates :crop_width, :crop_height,
|
|
@@ -40,8 +39,10 @@ module DynamicImage
|
|
|
40
39
|
numericality: { greater_than: 0, only_integer: true },
|
|
41
40
|
allow_nil: true
|
|
42
41
|
|
|
43
|
-
validates :
|
|
44
|
-
|
|
42
|
+
validates :crop_start_x, :crop_start_y,
|
|
43
|
+
numericality: { greater_than_or_equal_to: 0,
|
|
44
|
+
only_integer: true },
|
|
45
|
+
allow_nil: true
|
|
45
46
|
|
|
46
47
|
validates :crop_width, presence: true, if: :crop_height?
|
|
47
48
|
validates :crop_height, presence: true, if: :crop_width?
|
|
@@ -57,10 +58,16 @@ module DynamicImage
|
|
|
57
58
|
end
|
|
58
59
|
|
|
59
60
|
module ClassMethods
|
|
61
|
+
# Colorspaces an image is allowed to be in.
|
|
62
|
+
#
|
|
63
|
+
# @return [Array<String>] the colorspace names
|
|
60
64
|
def allowed_colorspaces
|
|
61
65
|
%w[rgb cmyk gray]
|
|
62
66
|
end
|
|
63
67
|
|
|
68
|
+
# Content types an image is allowed to have, taken from the registered formats.
|
|
69
|
+
#
|
|
70
|
+
# @return [Array<String>]
|
|
64
71
|
def allowed_content_types
|
|
65
72
|
DynamicImage::Format.content_types
|
|
66
73
|
end
|
|
@@ -4,9 +4,12 @@ module DynamicImage
|
|
|
4
4
|
module Model
|
|
5
5
|
# = DynamicImage Model Variants
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# Associates the image with its cached renderings.
|
|
8
|
+
#
|
|
9
|
+
# Each processed size is stored as a {DynamicImage::Variant}, so cropping and resizing is done once. Variants
|
|
10
|
+
# are destroyed with the image, and cleared whenever its data changes.
|
|
11
|
+
#
|
|
12
|
+
# @see DynamicImage::ProcessedImage
|
|
10
13
|
module Variants
|
|
11
14
|
extend ActiveSupport::Concern
|
|
12
15
|
|