rszr 0.5.2 → 1.0.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.
data/lib/rszr/image.rb CHANGED
@@ -1,20 +1,50 @@
1
1
  module Rszr
2
2
  class Image
3
-
3
+ GRAVITIES = [true, :center, :n, :nw, :w, :sw, :s, :se, :e, :ne].freeze
4
+
5
+ extend Identification
6
+ include Buffered
7
+ include Orientation
8
+
4
9
  class << self
5
-
6
- def load(path, options = {})
10
+
11
+ def load(path, autorotate: Rszr.autorotate, **opts)
7
12
  path = path.to_s
8
13
  raise FileNotFound unless File.exist?(path)
9
- _load(path)
14
+ image = _load(path)
15
+ autorotate(image, path) if autorotate
16
+ image
10
17
  end
11
18
  alias :open :load
12
19
 
20
+ def load_data(data, autorotate: Rszr.autorotate, **opts)
21
+ raise LoadError, 'Unknown format' unless format = identify(data)
22
+ with_tempfile(format, data) do |file|
23
+ load(file.path, autorotate: autorotate, **opts)
24
+ end
25
+ end
26
+
13
27
  end
14
28
 
15
29
  def dimensions
16
30
  [width, height]
17
31
  end
32
+
33
+ def format
34
+ fmt = _format
35
+ fmt == 'jpg' ? 'jpeg' : fmt
36
+ end
37
+
38
+ def format=(fmt)
39
+ fmt = fmt.to_s if fmt.is_a?(Symbol)
40
+ self._format = fmt
41
+ end
42
+
43
+ def [](x, y)
44
+ if x >= 0 && x <= width - 1 && y >= 0 && y <= height - 1
45
+ Color::RGBA.new(*_pixel(x, y))
46
+ end
47
+ end
18
48
 
19
49
  def inspect
20
50
  fmt = format
@@ -22,30 +52,116 @@ module Rszr
22
52
  "#<#{self.class.name}:0x#{object_id.to_s(16)} #{width}x#{height}#{fmt}>"
23
53
  end
24
54
 
25
- def resize(*args)
26
- _resize(false, *calculate_size(*args))
27
- end
55
+ module Transformations
56
+ def resize(*args, **opts)
57
+ _resize(false, *calculate_size(*args, **opts))
58
+ end
28
59
 
29
- def resize!(*args)
30
- _resize(true, *calculate_size(*args))
31
- end
60
+ def resize!(*args, **opts)
61
+ _resize(true, *calculate_size(*args, **opts))
62
+ end
32
63
 
33
- def crop(x, y, width, height)
34
- _crop(false, x, y, width, height)
35
- end
64
+ def crop(x, y, width, height)
65
+ _crop(false, x, y, width, height)
66
+ end
36
67
 
37
- def crop!(x, y, width, height)
38
- _crop(true, x, y, width, height)
39
- end
68
+ def crop!(x, y, width, height)
69
+ _crop(true, x, y, width, height)
70
+ end
71
+
72
+ def turn(orientation)
73
+ dup.turn!(orientation)
74
+ end
40
75
 
41
- def turn!(orientation)
42
- orientation = orientation.abs + 2 if orientation.negative?
43
- _turn!(orientation % 4)
76
+ def turn!(orientation)
77
+ orientation = orientation.abs + 2 if orientation.negative?
78
+ _turn!(orientation % 4)
79
+ end
80
+
81
+ def rotate(deg)
82
+ _rotate(false, deg.to_f * Math::PI / 180.0)
83
+ end
84
+
85
+ def rotate!(deg)
86
+ _rotate(true, deg.to_f * Math::PI / 180.0)
87
+ end
88
+
89
+ # horizontal
90
+ def flop
91
+ dup.flop!
92
+ end
93
+
94
+ # vertical
95
+ def flip
96
+ dup.flip!
97
+ end
98
+
99
+ def sharpen(radius)
100
+ dup.sharpen!(radius)
101
+ end
102
+
103
+ def sharpen!(radius)
104
+ raise ArgumentError, 'illegal radius' if radius < 0
105
+ _sharpen!(radius)
106
+ end
107
+
108
+ def blur(radius)
109
+ dup.blur!(radius)
110
+ end
111
+
112
+ def blur!(radius)
113
+ raise ArgumentError, 'illegal radius' if radius < 0
114
+ _sharpen!(-radius)
115
+ end
116
+
117
+ def filter(filter_expr)
118
+ dup.filter!(filter_expr)
119
+ end
120
+
121
+ def brighten!(value, r: nil, g: nil, b: nil, a: nil)
122
+ raise ArgumentError, 'illegal brightness' if value > 1 || value < -1
123
+ filter!("colormod(brightness=#{value.to_f});")
124
+ end
125
+
126
+ def brighten(*args, **opts)
127
+ dup.brighten!(*args, **opts)
128
+ end
129
+
130
+ def contrast!(value, r: nil, g: nil, b: nil, a: nil)
131
+ raise ArgumentError, 'illegal contrast (must be > 0)' if value < 0
132
+ filter!("colormod(contrast=#{value.to_f});")
133
+ end
134
+
135
+ def contrast(*args, **opts)
136
+ dup.contrast!(*args, **opts)
137
+ end
138
+
139
+ def gamma!(value, r: nil, g: nil, b: nil, a: nil)
140
+ #raise ArgumentError, 'illegal gamma (must be > 0)' if value < 0
141
+ filter!("colormod(gamma=#{value.to_f});")
142
+ end
143
+
144
+ def gamma(*args, **opts)
145
+ dup.gamma!(*args, **opts)
146
+ end
44
147
  end
148
+
149
+ include Transformations
45
150
 
46
- def save(path, format = nil)
47
- format ||= format_from_filename(path) || 'jpg'
48
- _save(path.to_s, format.to_s)
151
+ def save(path, format: nil, quality: nil)
152
+ format ||= format_from_filename(path) || self.format || 'jpg'
153
+ raise ArgumentError, "invalid quality #{quality.inspect}" if quality && !(0..100).cover?(quality)
154
+ ensure_path_is_writable(path)
155
+ _save(path.to_s, format.to_s, quality)
156
+ end
157
+
158
+ def save_data(format: nil, quality: nil)
159
+ format ||= self.format || 'jpg'
160
+ with_tempfile(format) do |file|
161
+ save(file.path, format: format, quality: quality)
162
+ file.rewind
163
+ file.read
164
+ end
49
165
  end
50
166
 
51
167
  private
@@ -56,53 +172,115 @@ module Rszr
56
172
  # :auto, 300 auto width, fit height
57
173
  # 400, 300, crop: :center_middle
58
174
  # 400, 300, background: rgba
59
- # 400, 300, aspect: false
175
+ # 400, 300, skew: true
60
176
 
61
- def calculate_size(*args)
62
- options = args.last.is_a?(Hash) ? args.pop : {}
63
- assert_valid_keys options, :crop, :background, :skew #:extend, :width, :height, :max_width, :max_height, :box
64
- original_width, original_height = width, height
65
- x, y, = 0, 0
177
+ def calculate_size(*args, crop: nil, skew: nil, inflate: true)
178
+ #options = args.last.is_a?(Hash) ? args.pop : {}
179
+ #assert_valid_keys options, :crop, :background, :skew #:extend, :width, :height, :max_width, :max_height, :box
66
180
  if args.size == 1
67
- scale = args.first
68
- raise ArgumentError, "scale #{scale.inspect} out of range" unless scale > 0 && scale < 1
69
- new_width = original_width.to_f * scale
70
- new_height = original_height.to_f * scale
181
+ calculate_size_for_scale(args.first)
71
182
  elsif args.size == 2
72
183
  box_width, box_height = args
73
- if :auto == box_width && box_height.is_a?(Numeric)
74
- new_height = box_height
75
- new_width = box_height.to_f / original_height.to_f * original_width.to_f
76
- elsif box_width.is_a?(Numeric) && :auto == box_height
77
- new_width = box_width
78
- new_height = box_width.to_f / original_width.to_f * original_height.to_f
184
+ if args.include?(:auto)
185
+ calculate_size_for_auto(box_width, box_height)
79
186
  elsif box_width.is_a?(Numeric) && box_height.is_a?(Numeric)
80
- if options[:skew]
81
- new_width, new_height = box_width, box_height
82
- elsif options[:crop]
83
- # TODO: calculate x, y offset if crop
187
+ if not inflate and width <= box_width and height <= box_height
188
+ [0, 0, width, height, width, height]
189
+ elsif skew
190
+ calculate_size_for_skew(box_width, box_height)
191
+ elsif crop
192
+ calculate_size_for_crop(box_width, box_height, crop)
84
193
  else
85
- scale = original_width.to_f / original_height.to_f
86
- box_scale = box_width.to_f / box_height.to_f
87
- if scale >= box_scale # wider
88
- new_width = box_width
89
- new_height = original_height.to_f * box_width.to_f / original_width.to_f
90
- else # narrower
91
- new_height = box_height
92
- new_width = original_width.to_f * box_height.to_f / original_height.to_f
93
- end
194
+ calculate_size_for_limit(box_width, box_height)
94
195
  end
95
- else
96
- raise ArgumentError, "unconclusive arguments #{args.inspect} #{options.inspect}"
97
196
  end
98
197
  else
99
198
  raise ArgumentError, "wrong number of arguments (#{args.size} for 1..2)"
100
199
  end
101
- [x, y, original_width, original_height, new_width.round, new_height.round]
102
200
  end
103
201
 
202
+ def calculate_size_for_scale(factor)
203
+ raise ArgumentError, "scale factor #{factor.inspect} out of range" unless factor > 0 && factor < 1
204
+ [0, 0, width, height, (width.to_f * factor).round, (height.to_f * factor).round]
205
+ end
206
+
207
+ def calculate_size_for_skew(box_width, box_height)
208
+ [0, 0, width, height, box_width, box_height]
209
+ end
210
+
211
+ def calculate_size_for_auto(box_width, box_height)
212
+ if :auto == box_width && box_height.is_a?(Numeric)
213
+ new_height = box_height
214
+ new_width = (box_height.to_f / height.to_f * width.to_f).round
215
+ elsif box_width.is_a?(Numeric) && :auto == box_height
216
+ new_width = box_width
217
+ new_height = (box_width.to_f / width.to_f * height.to_f).round
218
+ else
219
+ raise ArgumentError, "unconclusive arguments #{box_width.inspect}, #{box_height.inspect}"
220
+ end
221
+ [0, 0, width, height, new_width, new_height]
222
+ end
223
+
224
+ def calculate_size_for_crop(box_width, box_height, crop)
225
+ raise ArgumentError, "invalid crop gravity" unless GRAVITIES.include?(crop)
226
+ aspect = width.to_f / height.to_f
227
+ box_aspect = box_width.to_f / box_height.to_f
228
+ if aspect >= box_aspect # wider than box
229
+ src_width = (box_width.to_f * height.to_f / box_height.to_f).round
230
+ src_height = height
231
+ x = crop_horizontally(src_width, crop)
232
+ y = 0
233
+ else # narrower than box
234
+ src_width = width
235
+ src_height = (box_height.to_f * width.to_f / box_width.to_f).round
236
+ x = 0
237
+ y = crop_vertically(src_height, crop)
238
+ end
239
+ [x, y, src_width, src_height, box_width, box_height]
240
+ end
241
+
242
+ def crop_horizontally(src_width, crop)
243
+ case crop
244
+ when :nw, :w, :sw then 0
245
+ when :ne, :e, :se then width - src_width
246
+ else
247
+ ((width - src_width).to_f / 2.to_f).round
248
+ end
249
+ end
250
+
251
+ def crop_vertically(src_height, crop)
252
+ case crop
253
+ when :nw, :n, :ne then 0
254
+ when :sw, :s, :se then height - src_height
255
+ else
256
+ ((height - src_height).to_f / 2.to_f).round
257
+ end
258
+ end
259
+
260
+ def calculate_size_for_limit(box_width, box_height)
261
+ scale = width.to_f / height.to_f
262
+ box_scale = box_width.to_f / box_height.to_f
263
+ if scale >= box_scale # wider
264
+ new_width = box_width
265
+ new_height = (height.to_f * box_width.to_f / width.to_f).round
266
+ else # narrower
267
+ new_height = box_height
268
+ new_width = (width.to_f * box_height.to_f / height.to_f).round
269
+ end
270
+ [0, 0, width, height, new_width, new_height]
271
+ end
272
+
104
273
  def format_from_filename(path)
105
- File.extname(path)[1..-1]
274
+ File.extname(path)[1..-1].to_s.downcase
275
+ end
276
+
277
+ def ensure_path_is_writable(path)
278
+ path = Pathname.new(path)
279
+ path.dirname.realpath.writable?
280
+ rescue Errno::ENOENT => e
281
+ raise SaveError, 'Non-existant path component'
282
+ rescue SystemCallError => e
283
+ raise SaveError, e.message
106
284
  end
107
285
 
108
286
  def assert_valid_keys(hsh, *valid_keys)
@@ -0,0 +1,82 @@
1
+ require 'rszr'
2
+ require 'image_processing'
3
+
4
+ module ImageProcessing
5
+ module Rszr
6
+ extend Chainable
7
+
8
+ class << self
9
+
10
+ # Returns whether the given image file is processable.
11
+ def valid_image?(file)
12
+ ::Rszr::Image.load(file).width
13
+ true
14
+ rescue ::Rszr::Error
15
+ false
16
+ end
17
+
18
+ end
19
+
20
+ class Processor < ImageProcessing::Processor
21
+ accumulator :image, ::Rszr::Image
22
+
23
+ class << self
24
+
25
+ # Loads the image on disk into a Rszr::Image object
26
+ def load_image(path_or_image, **options)
27
+ if path_or_image.is_a?(::Rszr::Image)
28
+ path_or_image
29
+ else
30
+ ::Rszr::Image.load(path_or_image)
31
+ end
32
+ # TODO: image = image.autorot if autorot && !options.key?(:autorotate)
33
+ end
34
+
35
+ # Writes the image object to disk.
36
+ # Accepts additional options (quality, format).
37
+ def save_image(image, destination_path, **options)
38
+ image.save(destination_path, **options)
39
+ end
40
+
41
+ # Calls the operation to perform the processing. If the operation is
42
+ # defined on the processor (macro), calls it. Otherwise calls the
43
+ # bang variant of the method directly on the Rszr image object.
44
+ def apply_operation(accumulator, (name, args, block))
45
+ return super if method_defined?(name)
46
+ accumulator.send("#{name}!", *args, &block)
47
+ end
48
+
49
+ end
50
+
51
+ # Resizes the image to not be larger than the specified dimensions.
52
+ def resize_to_limit(width, height, **options)
53
+ width, height = default_dimensions(width, height)
54
+ thumbnail(width, height, inflate: false, **options)
55
+ end
56
+
57
+ # Resizes the image to fit within the specified dimensions.
58
+ def resize_to_fit(width, height, **options)
59
+ width, height = default_dimensions(width, height)
60
+ thumbnail(width, height, **options)
61
+ end
62
+
63
+ # Resizes the image to fill the specified dimensions, applying any
64
+ # necessary cropping.
65
+ def resize_to_fill(width, height, gravity: :center, **options)
66
+ thumbnail(width, height, crop: gravity, **options)
67
+ end
68
+
69
+ private
70
+
71
+ def thumbnail(width, height, **options)
72
+ image.resize!(width, height, **options)
73
+ end
74
+
75
+ def default_dimensions(width, height)
76
+ raise Error, 'either width or height must be specified' unless width || height
77
+ [width || :auto, height || :auto]
78
+ end
79
+
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,107 @@
1
+ module Rszr
2
+ module Orientation
3
+ ROTATIONS = { 5 => 1, 6 => 1, 3 => 2, 4 => 2, 7 => 3, 8 => 3 }
4
+
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ base.attr_reader :original_orientation
8
+ end
9
+
10
+ module ClassMethods
11
+
12
+ private
13
+
14
+ def autorotate(image, path)
15
+ return unless %w[jpeg tiff].include?(image.format)
16
+ File.open(path) do |file|
17
+ if orientation = send("parse_#{image.format}_orientation", file) and (1..8).member?(orientation)
18
+ image.instance_variable_set :@original_orientation, orientation
19
+ image.flop! if [2, 4, 5, 7].include?(orientation)
20
+ image.turn!(ROTATIONS[orientation]) if ROTATIONS.key?(orientation)
21
+ end
22
+ end
23
+ end
24
+
25
+ def parse_tiff_orientation(data)
26
+ exif_parse_orientation(Stream.new(data))
27
+ end
28
+
29
+ def parse_jpeg_orientation(data)
30
+ stream = Stream.new(data)
31
+ exif = nil
32
+ state = nil
33
+ loop do
34
+ state = case state
35
+ when nil
36
+ stream.skip(2)
37
+ :started
38
+ when :started
39
+ stream.read_byte == 0xFF ? :sof : :started
40
+ when :sof
41
+ case stream.read_byte
42
+ when 0xe1 # APP1
43
+ skip_chars = stream.read_int - 2
44
+ app1 = Stream.new(stream.read(skip_chars))
45
+ if app1.read(4) == 'Exif'
46
+ app1.skip(2)
47
+ orientation = exif_parse_orientation(app1.fast_forward)# rescue nil
48
+ return orientation
49
+ end
50
+ :started
51
+ when 0xe0..0xef
52
+ :skipframe
53
+ when 0xC0..0xC3, 0xC5..0xC7, 0xC9..0xCB, 0xCD..0xCF
54
+ :readsize
55
+ when 0xFF
56
+ :sof
57
+ else
58
+ :skipframe
59
+ end
60
+ when :skipframe
61
+ skip_chars = stream.read_int - 2
62
+ stream.skip(skip_chars)
63
+ :started
64
+ when :readsize
65
+ # stream.skip(3)
66
+ # height = stream.read_int
67
+ # width = stream.read_int
68
+ return exif&.orientation
69
+ end
70
+ end
71
+ end
72
+
73
+ def exif_byte_order(stream)
74
+ byte_order = stream.read(2)
75
+ case byte_order
76
+ when 'II'
77
+ %w[v V]
78
+ when 'MM'
79
+ %w[n N]
80
+ else
81
+ raise LoadError
82
+ end
83
+ end
84
+
85
+ def exif_parse_ifd(stream, short)
86
+ tag_count = stream.read(2).unpack(short)[0]
87
+ tag_count.downto(1) do
88
+ type = stream.read(2).unpack(short)[0]
89
+ stream.read(6)
90
+ data = stream.read(2).unpack(short)[0]
91
+ return data if 0x0112 == type
92
+ stream.read(2)
93
+ end
94
+ nil
95
+ end
96
+
97
+ def exif_parse_orientation(stream)
98
+ short, long = exif_byte_order(stream)
99
+ stream.read(2) # 42
100
+ offset = stream.read(4).unpack(long)[0]
101
+ stream.skip(offset - 8)
102
+ exif_parse_ifd(stream, short)
103
+ end
104
+ end
105
+
106
+ end
107
+ end
@@ -0,0 +1,61 @@
1
+ module Rszr
2
+ class Stream
3
+ attr_reader :pos, :data
4
+ protected :data
5
+
6
+ def initialize(data, start: 0)
7
+ raise ArgumentError, 'start must be > 0' if start < 0
8
+ @data = case data
9
+ when IO then data
10
+ when String then StringIO.new(data)
11
+ when Stream then data.data
12
+ else
13
+ raise ArgumentError, "data must be File or String, got #{data.class}"
14
+ end
15
+ @data.binmode
16
+ @data.seek(start)
17
+ @pos = 0
18
+ end
19
+
20
+ def read(n)
21
+ @data.read(n).tap { @pos += n }
22
+ end
23
+
24
+ def peek(n)
25
+ old_pos = @data.pos
26
+ @data.read(n)
27
+ ensure
28
+ @data.pos = old_pos
29
+ end
30
+
31
+ def skip(n)
32
+ @data.seek(n, IO::SEEK_CUR).tap { @pos += n }
33
+ end
34
+
35
+ def substream
36
+ self.class.new(self, @data.pos)
37
+ end
38
+
39
+ def fast_forward
40
+ @pos = 0
41
+ self
42
+ end
43
+
44
+ def read_byte
45
+ read(1)[0].ord
46
+ end
47
+
48
+ def read_int
49
+ read(2).unpack('n')[0]
50
+ end
51
+
52
+ def read_string_int
53
+ value = []
54
+ while read(1) =~ /(\d)/
55
+ value << $1
56
+ end
57
+ value.join.to_i
58
+ end
59
+
60
+ end
61
+ end
data/lib/rszr/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Rszr
2
- VERSION = "0.5.2"
2
+ VERSION = '1.0.0'
3
3
  end
data/lib/rszr.rb CHANGED
@@ -1,6 +1,27 @@
1
1
  require 'rbconfig'
2
2
  require 'pathname'
3
+ require 'tempfile'
4
+ require 'stringio'
3
5
 
4
6
  require 'rszr/rszr'
5
7
  require 'rszr/version'
8
+ require 'rszr/stream'
9
+ require 'rszr/identification'
10
+ require 'rszr/orientation'
11
+ require 'rszr/buffered'
12
+ require 'rszr/color'
6
13
  require 'rszr/image'
14
+
15
+ module Rszr
16
+ class << self
17
+ @@autorotate = nil
18
+
19
+ def autorotate
20
+ @@autorotate
21
+ end
22
+
23
+ def autorotate=(value)
24
+ @@autorotate = !!value
25
+ end
26
+ end
27
+ end