morandi 0.99.03 → 0.100.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a794ac03476df828589c8239af24558daba1b32c4db9052f1dcf3704442eae43
4
- data.tar.gz: 71fb0af31081d29907392d57ceb7adda9fc6b277835fd7540a9c91a6b1b9c90c
3
+ metadata.gz: 0ca1f4a637e59ee90de4f309dfecc40eae27b103f416ea301af36e8bd04c555f
4
+ data.tar.gz: 8ebeda3024e6275ea7e8b75c8357339523a59a24987e1ebd07142cba445edce7
5
5
  SHA512:
6
- metadata.gz: be85f2a459dad73012fe696f285f1eae17b098e48002f5da9cf868ed876854b915115e1e6f85929948427208d2b3363abe3d7b492b6187e729bf408ad6fccb95
7
- data.tar.gz: 78beadc94f1387ff9af6d9c00d2f518f17d71f241880a5d2a828568dfaa2eb1b2aade57b198fc7f8ceeae429c1445d1c9c36c9fd461c84b3516e88964c32bf19
6
+ metadata.gz: 55308d1a2ae4626a81bf1faa9a6ae0535f816d777e31d25266e36e563bdc1dc60c55ef588ade477ce113a13bd20f80aefd046a52c1e4aaccffdc103e48d75cac
7
+ data.tar.gz: c10ab8cbe21121d50a2c0a10cdb6c0fefe595962d11cf936adeaef78e8291bd4688b37bb464577c7f455d8aa02e04def65d46930a86fb3cbf9c1aed4e9abf972
data/CHANGELOG.md CHANGED
@@ -4,7 +4,40 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
5
5
  and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## Unreleased
7
+ ## [0.100.0] 17.01.2024
8
+ ### Added
9
+ - Vips image processor (resizing)
10
+ - Vips colour filters
11
+ - Vips crop
12
+ - Vips rotate
13
+ - Vips straighten
14
+ - Vips gamma
15
+ - Vips stripping alpha
16
+ - Explicit error when trying to use VipsProcessor with unsupported options
17
+ - Vips cast to srgb when image uses a different colourspace
18
+
19
+ ### Removed
20
+ - [BREAKING] dropped support for a broken 'dominant' border colour
21
+
22
+ ## [0.99.4] 22.11.2024
23
+ ### Added
24
+ - Better test coverage for straighten operation
25
+ - Support for visual image comparison in specs
26
+ - Automated visual comparison of images in specs, also serving as a record of exact rendering behaviour
27
+ - Development scripts for performing benchmarks
28
+ - Basic test coverage for transparency support
29
+
30
+ ### Changed
31
+ - Extracted image operations to separate files within a dedicated module
32
+ - [BREAKING] Introduced raising Morandi's own errors instead of bubbling Pixbuf's
33
+
34
+ ### Fixed
35
+ - Updated required ruby version in gemspec to reflect dropping Ruby 2.3 support
36
+
37
+ ### Removed
38
+ - [BREAKING] `config` accessor in ImageProcessor removed in favour of `user_options` supplied in constructor
39
+
40
+ ## [0.99.03] 19.09.2024
8
41
  ### Added
9
42
  - Copied pixbufutils and redeye gems into main gem
10
43
  - Added gdk_pixbuf_cairo C extension to convert between GdkPixbufs and ImageSurfaces
@@ -12,15 +45,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
12
45
  - Bumped version to 0.99.01 in preparation for a 1.0 release
13
46
 
14
47
  ### Removed
15
- - support for Ruby 2.0 (and illusion of it being tested by CI)
16
- - support for Ruby 2.3
48
+ - [BREAKING] support for Ruby 2.0 (and illusion of it being tested by CI)
49
+ - [BREAKING] support for Ruby 2.3
17
50
  - gtk2 dependency
18
51
 
19
52
  ## [0.13.0] 16.12.2020
20
53
  ### Fixed
21
54
  - Refactored test suite
22
55
  - Fixed most rubocop offenses
23
- ### Aded
56
+ ### Added
24
57
  - CI pipeline
25
58
  - rubocop
26
59
  - Development image
@@ -32,7 +65,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
32
65
  ## [0.12.0] 10.12.2020
33
66
  ### Fixed
34
67
  - Compatability with gdk_pixbuf v3.4.0+ [TECH-14001]
35
- ### Aded
68
+ ### Added
36
69
  - .ruby-version file
37
70
 
38
71
 
data/README.md CHANGED
@@ -1,11 +1,10 @@
1
1
  # Morandi
2
2
 
3
- Library of simple image manipulations - replicating the behaviour of
4
- morandi-js.
3
+ Library of simple image manipulations - replicating the behaviour of morandi-js.
5
4
 
6
5
  ## Installation
7
6
 
8
- Install `liblcms2-utils` to provide the `jpgicc` command used by `Morandi::ProfiledPixbuf`. Also ensure that your host system has `imagemagick` installed, which is required bi the `colorscore` gem.
7
+ Install `liblcms2-utils` to provide the `jpgicc` command used by `Morandi::ProfiledPixbuf`. Also ensure that your host system has `imagemagick` installed, which is required by the `colorscore` gem.
9
8
 
10
9
  Add this line to your application's Gemfile:
11
10
 
@@ -22,26 +21,10 @@ Or install it yourself as:
22
21
  ## Usage
23
22
 
24
23
  ````
25
- Morandi.process(in_file, settings, out_file)
24
+ Morandi.process(source, options, target_path)
26
25
  ````
27
- - in_file is a string
28
- - settings is a hash
29
- - out_file is a string
30
-
31
- Settings Key | Values | Description
32
- -------------|--------|---------------
33
- brighten | Integer -20..20 | Change image brightness
34
- gamma | Float | Gamma correct image
35
- contrast | Integer -20..20 | Change image contrast
36
- sharpen | Integer -5..5 | Sharpen / Blur (negative value)
37
- redeye | Array[[Integer,Integer],...] | Apply redeye correction at point
38
- angle | Integer 0,90,180,270 | Rotate image
39
- straighten | Float | Rotate by N degrees and zoom
40
- crop | Array[Integer,Integer,Integer,Integer] | Crop image
41
- fx | String greyscale,sepia,bluetone | Apply colour filters
42
- border-style | String square,retro | Set border style
43
- background-style | String retro,black,white | Set border colour
44
- quality | String '1'..'100' | Set JPG compression value, defaults to 97%
26
+
27
+ For the detailed documentation of options see `lib/morandi.rb`
45
28
 
46
29
  ## Contributing
47
30
 
@@ -53,4 +36,4 @@ quality | String '1'..'100' | Set JPG compression value, defaults to 97%
53
36
 
54
37
  ### Development
55
38
 
56
- Since this gem depends on the `liblcms2-utils` library, which can be awkward to install on some operating systems, we also provide a development docker image. A Makefile is also provided as a simple CLI. To build the image and run the container, type `make` from the project root. The container itself runs `guard` as its main process. Running the container via `make` will drop you into the guard prompt, which will run the test suite whenever any of the source code or tests are changed. The tests can be kicked-off manually via the `all` command at the guard prompt. Individual test can be run using the `focus: true` annotation on an example or describe block. If you need to access a bash shell in the container (for example, to run rubocop), use the command `make shell`.
39
+ Since this gem depends on the `liblcms2-utils` library, which can be awkward to install on some operating systems, we also provide a development docker image. A Makefile is also provided as a simple CLI. To build the image and run the container, type `make` from the project root. The container itself runs `guard` as its main process. Running the container via `make` will drop you into the guard prompt, which will run the test suite whenever any of the source code or tests are changed. The tests can be kicked-off manually via the `all` command at the guard prompt. Individual test can be run using the `focus: true` annotation on an example or describe block. If you need to access a bash shell in the container (for example, to run rubocop), use the command `make shell`.
Binary file
@@ -91,5 +91,39 @@ module Morandi
91
91
  end
92
92
  pixbuf
93
93
  end
94
+
95
+ def apply_crop_vips(img, x_coord, y_coord, width, height)
96
+ if x_coord.negative? ||
97
+ y_coord.negative? ||
98
+ ((x_coord + width) > img.width) ||
99
+ ((y_coord + height) > img.height)
100
+
101
+ extract_area_x = [0, x_coord].max
102
+ extract_area_y = [0, y_coord].max
103
+ area_to_copy = img.extract_area(extract_area_x, extract_area_y, img.width - extract_area_x,
104
+ img.height - extract_area_y)
105
+
106
+ fill_colour = [255, 255, 255]
107
+ pixel = (Vips::Image.black(1, 1).colourspace(:srgb) + fill_colour).cast(img.format)
108
+ canvas = pixel.embed 0, 0, width, height, extend: :copy
109
+
110
+ cropped = canvas.composite(area_to_copy, :over, x: [-x_coord, 0].max,
111
+ y: [-y_coord, 0].max,
112
+ compositing_space: area_to_copy.interpretation)
113
+
114
+ # Because image is drawn on an opaque white, alpha doesn't matter at this point anyway, so let's strip the
115
+ # alpha channel from the output. According to #composite docs, the resulting image always has alpha channel,
116
+ # but I added a guard to avoid regressions if that ever changes.
117
+ cropped = cropped.extract_band(0, n: cropped.bands - 1) if cropped.has_alpha?
118
+ cropped
119
+ else
120
+ x_coord = x_coord.clamp(0, img.width)
121
+ y_coord = y_coord.clamp(0, img.height)
122
+ width = width.clamp(1, img.width - x_coord)
123
+ height = height.clamp(1, img.height - y_coord)
124
+
125
+ img.crop(x_coord, y_coord, width, height)
126
+ end
127
+ end
94
128
  end
95
129
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Morandi
4
+ class Error < StandardError
5
+ end
6
+
7
+ class CorruptImageError < Error
8
+ end
9
+
10
+ class UnknownTypeError < Error
11
+ end
12
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'colorscore'
4
-
5
3
  module Morandi
6
4
  # Base Image Op class
7
5
  # @!visibility private
@@ -30,176 +28,4 @@ module Morandi
30
28
  final_pb
31
29
  end
32
30
  end
33
-
34
- # Straighten operation
35
- # Does a small (ie. not 90,180,270 deg) rotation and zooms to avoid cropping
36
- # @!visibility private
37
- class Straighten < ImageOperation
38
- attr_accessor :angle
39
-
40
- def call(_image, pixbuf)
41
- return pixbuf if angle.zero?
42
-
43
- rotation_value_rad = angle * (Math::PI / 180)
44
-
45
- ratio = pixbuf.width.to_f / pixbuf.height
46
- rh = pixbuf.height / ((ratio * Math.sin(rotation_value_rad.abs)) + Math.cos(rotation_value_rad.abs))
47
- scale = pixbuf.height / rh.to_f.abs
48
-
49
- a_ratio = pixbuf.height.to_f / pixbuf.width
50
- a_rh = pixbuf.width / ((a_ratio * Math.sin(rotation_value_rad.abs)) + Math.cos(rotation_value_rad.abs))
51
- a_scale = pixbuf.width / a_rh.to_f.abs
52
-
53
- scale = a_scale if a_scale > scale
54
-
55
- create_pixbuf_from_image_surface(:rgb24, pixbuf.width, pixbuf.height) do |cr|
56
- cr.translate(pixbuf.width / 2.0, pixbuf.height / 2.0)
57
- cr.rotate(rotation_value_rad)
58
- cr.scale(scale, scale)
59
- cr.translate(pixbuf.width / -2.0, pixbuf.height / - 2.0)
60
- cr.set_source_pixbuf(pixbuf)
61
-
62
- cr.rectangle(0, 0, pixbuf.width, pixbuf.height)
63
- cr.paint(1.0)
64
- end
65
- end
66
- end
67
-
68
- # Image Border operation
69
- # Supports retro (rounded) and square borders
70
- # Background colour (ie. border colour) can be white, black, dominant (ie. from image)
71
- # @!visibility private
72
- class ImageBorder < ImageOperation
73
- attr_accessor :style, :colour, :crop, :size, :print_size, :shrink, :border_size
74
-
75
- def call(_image, pixbuf)
76
- return pixbuf unless %w[square retro].include? @style
77
-
78
- create_pixbuf_from_image_surface(:rgb24, pixbuf.width, pixbuf.height) do |cr|
79
- if @crop && ((@crop[0]).negative? || (@crop[1]).negative?)
80
- img_width = size[0]
81
- img_height = size[1]
82
- else
83
- img_width = pixbuf.width
84
- img_height = pixbuf.height
85
- end
86
-
87
- @border_scale = [img_width, img_height].max.to_f / print_size.max.to_i
88
-
89
- draw_background(cr, img_height, img_width, pixbuf)
90
-
91
- x = border_width
92
- y = border_width
93
-
94
- # This biggest impact will be on the smallest side, so to avoid white
95
- # edges between photo and border scale by the longest changed side.
96
- longest_side = [pixbuf.width, pixbuf.height].max.to_f
97
-
98
- # Should be less than 1
99
- pb_scale = (longest_side - (border_width * 2)) / longest_side
100
-
101
- if @crop && ((@crop[0]).negative? || (@crop[1]).negative?)
102
- x -= @crop[0]
103
- y -= @crop[1]
104
- end
105
-
106
- draw_pixbuf(pixbuf, cr, img_height, img_width, pb_scale, x, y)
107
- end
108
- end
109
-
110
- private
111
-
112
- # Width is proportional to output size
113
- def border_width
114
- @border_size * @border_scale
115
- end
116
-
117
- def draw_pixbuf(pixbuf, cr, img_height, img_width, pb_scale, x, y)
118
- case style
119
- when 'retro'
120
- Morandi::CairoExt.rounded_rectangle(cr, x, y,
121
- img_width + x - (border_width * 2),
122
- img_height + y - (border_width * 2), border_width)
123
- when 'square'
124
- cr.rectangle(x, y, img_width - (border_width * 2), img_height - (border_width * 2))
125
- end
126
- cr.clip
127
-
128
- if @shrink
129
- cr.translate(border_width, border_width)
130
- cr.scale(pb_scale, pb_scale)
131
- end
132
- cr.set_source_pixbuf(pixbuf)
133
- cr.rectangle(0, 0, pixbuf.width, pixbuf.height)
134
-
135
- cr.paint(1.0)
136
- end
137
-
138
- def draw_background(cr, img_height, img_width, pixbuf)
139
- cr.save do
140
- cr.translate(-@crop[0], -@crop[1]) if @crop && ((@crop[0]).negative? || (@crop[1]).negative?)
141
-
142
- cr.save do
143
- cr.set_operator :source
144
- cr.set_source_rgb 1, 1, 1
145
- cr.paint
146
-
147
- cr.rectangle(0, 0, img_width, img_height)
148
- case colour
149
- when 'dominant'
150
- pixbuf.scale_max(400).save(fn = "/tmp/hist-#{$PROCESS_ID}.#{Time.now.to_i}", 'jpeg')
151
- histogram = Colorscore::Histogram.new(fn)
152
- FileUtils.rm_f(fn)
153
- col = histogram.scores.first[1]
154
- cr.set_source_rgb col.red / 256.0, col.green / 256.0, col.blue / 256.0
155
- when 'retro'
156
- cr.set_source_rgb 1, 1, 0.8
157
- when 'black'
158
- cr.set_source_rgb 0, 0, 0
159
- else
160
- cr.set_source_rgb 1, 1, 1
161
- end
162
- cr.fill
163
- end
164
- end
165
- end
166
- end
167
-
168
- # Colourify Operation
169
- # Apply tint to image with variable strength
170
- # Supports filter, alpha
171
- class Colourify < ImageOperation
172
- attr_reader :filter
173
-
174
- def alpha
175
- @alpha || 255
176
- end
177
-
178
- def sepia(pixbuf)
179
- MorandiNative::PixbufUtils.tint(pixbuf, 25, 5, -25, alpha)
180
- end
181
-
182
- def bluetone(pixbuf)
183
- MorandiNative::PixbufUtils.tint(pixbuf, -10, 5, 25, alpha)
184
- end
185
-
186
- def null(pixbuf)
187
- pixbuf
188
- end
189
- alias full null # WebKiosk
190
- alias colour null # WebKiosk
191
-
192
- def greyscale(pixbuf)
193
- MorandiNative::PixbufUtils.tint(pixbuf, 0, 0, 0, alpha)
194
- end
195
- alias bw greyscale # WebKiosk
196
-
197
- def call(_image, pixbuf)
198
- if @filter && respond_to?(@filter)
199
- __send__(@filter, pixbuf)
200
- else
201
- pixbuf # Default is nothing
202
- end
203
- end
204
- end
205
31
  end
@@ -2,6 +2,9 @@
2
2
 
3
3
  require 'morandi/profiled_pixbuf'
4
4
  require 'morandi/redeye'
5
+ require 'morandi/operation/straighten'
6
+ require 'morandi/operation/colourify'
7
+ require 'morandi/operation/image_border'
5
8
 
6
9
  module Morandi
7
10
  # rubocop:disable Metrics/ClassLength
@@ -9,7 +12,6 @@ module Morandi
9
12
  # ImageProcessor transforms an image.
10
13
  class ImageProcessor
11
14
  attr_reader :options, :pb
12
- attr_accessor :config
13
15
 
14
16
  def initialize(file, user_options, local_options = {})
15
17
  @file = file
@@ -20,7 +22,7 @@ module Morandi
20
22
  @options = (local_options || {}).merge(user_options || {})
21
23
  @local_options = local_options
22
24
 
23
- @scale_to = @options['output.max']
25
+ @max_size_px = @options['output.max']
24
26
  @width = @options['output.width']
25
27
  @height = @options['output.height']
26
28
  end
@@ -55,6 +57,10 @@ module Morandi
55
57
  @pb = @pb.scale_max([@width, @height].max) if @options['output.limit'] && @width && @height
56
58
 
57
59
  @pb
60
+ rescue GdkPixbuf::PixbufError::UnknownType => e
61
+ raise UnknownTypeError, e.message
62
+ rescue GdkPixbuf::PixbufError::CorruptImage => e
63
+ raise CorruptImageError, e.message
58
64
  end
59
65
 
60
66
  # Returns generated pixbuf
@@ -84,16 +90,18 @@ module Morandi
84
90
 
85
91
  def get_pixbuf
86
92
  _, width, height = GdkPixbuf::Pixbuf.get_file_info(@file)
87
- @pb = Morandi::ProfiledPixbuf.new(@file, @local_options, @scale_to)
88
- @actual_max = [@pb.width, @pb.height].max
89
-
90
- @src_max = if @scale_to
91
- [width, height].max
92
- else
93
- [@pb.width, @pb.height].max
94
- end
95
-
96
- @scale = @actual_max / @src_max.to_f
93
+ @pb = Morandi::ProfiledPixbuf.new(@file, @local_options, @max_size_px)
94
+
95
+ # Everything below probably could be substituted with the following:
96
+ # @scale = @max_size_px ? @max_size_px / [width, height].max : 1.0
97
+ actual_max = [@pb.width, @pb.height].max
98
+ src_max = if @max_size_px
99
+ [width, height].max
100
+ else
101
+ [@pb.width, @pb.height].max
102
+ end
103
+
104
+ @scale = actual_max / src_max.to_f
97
105
  end
98
106
 
99
107
  SHARPEN = [
@@ -159,8 +167,7 @@ module Morandi
159
167
  @pb = @pb.rotate(a) unless (a % 360).zero?
160
168
 
161
169
  unless options['straighten'].to_f.zero?
162
- @pb = Morandi::Straighten.new_from_hash(angle: options['straighten'].to_f).call(nil,
163
- @pb)
170
+ @pb = Morandi::Operation::Straighten.new_from_hash(angle: options['straighten'].to_f).call(@pb)
164
171
  end
165
172
 
166
173
  @image_width = @pb.width
@@ -172,7 +179,6 @@ module Morandi
172
179
  }.freeze
173
180
  def config_for(key)
174
181
  return options[key] if options&.key?(key)
175
- return @config[key] if @config&.key?(key)
176
182
 
177
183
  DEFAULT_CONFIG[key]
178
184
  end
@@ -203,11 +209,11 @@ module Morandi
203
209
 
204
210
  case filter
205
211
  when 'greyscale', 'sepia', 'bluetone'
206
- op = Morandi::Colourify.new_from_hash('filter' => filter)
212
+ op = Morandi::Operation::Colourify.new_from_hash('filter' => filter)
207
213
  else
208
214
  return
209
215
  end
210
- @pb = op.call(nil, @pb)
216
+ @pb = op.call(@pb)
211
217
  end
212
218
 
213
219
  def apply_decorations!
@@ -222,7 +228,7 @@ module Morandi
222
228
  crop = options['crop']
223
229
  crop = crop.map { |s| (s.to_f * @scale).floor } if crop && not_equal_to_one(@scale)
224
230
 
225
- op = Morandi::ImageBorder.new_from_hash(
231
+ op = Morandi::Operation::ImageBorder.new_from_hash(
226
232
  'style' => style,
227
233
  'colour' => colour || '#000000',
228
234
  'crop' => crop,
@@ -232,7 +238,7 @@ module Morandi
232
238
  'border_size' => @scale * config_for('border-size-mm').to_i * 300 / 25.4 # 5mm at 300dpi
233
239
  )
234
240
 
235
- @pb = op.call(nil, @pb)
241
+ @pb = op.call(@pb)
236
242
  end
237
243
 
238
244
  private
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'morandi/image_operation'
4
+
5
+ module Morandi
6
+ module Operation
7
+ # Colourify Operation
8
+ # Apply tint to image with variable strength
9
+ # Supports filter, alpha
10
+ class Colourify < ImageOperation
11
+ attr_reader :filter
12
+
13
+ def alpha
14
+ @alpha || 255
15
+ end
16
+
17
+ def sepia(pixbuf)
18
+ MorandiNative::PixbufUtils.tint(pixbuf, 25, 5, -25, alpha)
19
+ end
20
+
21
+ def bluetone(pixbuf)
22
+ MorandiNative::PixbufUtils.tint(pixbuf, -10, 5, 25, alpha)
23
+ end
24
+
25
+ def null(pixbuf)
26
+ pixbuf
27
+ end
28
+ alias full null # WebKiosk
29
+ alias colour null # WebKiosk
30
+
31
+ def greyscale(pixbuf)
32
+ MorandiNative::PixbufUtils.tint(pixbuf, 0, 0, 0, alpha)
33
+ end
34
+ alias bw greyscale # WebKiosk
35
+
36
+ def call(pixbuf)
37
+ if @filter && respond_to?(@filter)
38
+ __send__(@filter, pixbuf)
39
+ else
40
+ pixbuf # Default is nothing
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'colorscore'
4
+ require 'morandi/image_operation'
5
+
6
+ module Morandi
7
+ module Operation
8
+ # Image Border operation
9
+ # Supports retro (rounded) and square borders
10
+ # Background colour (ie. border colour) can be white, black
11
+ # @!visibility private
12
+ class ImageBorder < ImageOperation
13
+ attr_accessor :style, :colour, :crop, :size, :print_size, :shrink, :border_size
14
+
15
+ def call(pixbuf)
16
+ return pixbuf unless %w[square retro].include? @style
17
+
18
+ create_pixbuf_from_image_surface(:rgb24, pixbuf.width, pixbuf.height) do |cr|
19
+ if @crop && ((@crop[0]).negative? || (@crop[1]).negative?)
20
+ img_width = size[0]
21
+ img_height = size[1]
22
+ else
23
+ img_width = pixbuf.width
24
+ img_height = pixbuf.height
25
+ end
26
+
27
+ @border_scale = [img_width, img_height].max.to_f / print_size.max.to_i
28
+
29
+ draw_background(cr, img_height, img_width)
30
+
31
+ x = border_width
32
+ y = border_width
33
+
34
+ # This biggest impact will be on the smallest side, so to avoid white
35
+ # edges between photo and border scale by the longest changed side.
36
+ longest_side = [pixbuf.width, pixbuf.height].max.to_f
37
+
38
+ # Should be less than 1
39
+ pb_scale = (longest_side - (border_width * 2)) / longest_side
40
+
41
+ if @crop && ((@crop[0]).negative? || (@crop[1]).negative?)
42
+ x -= @crop[0]
43
+ y -= @crop[1]
44
+ end
45
+
46
+ draw_pixbuf(pixbuf, cr, img_height, img_width, pb_scale, x, y)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # Width is proportional to output size
53
+ def border_width
54
+ @border_size * @border_scale
55
+ end
56
+
57
+ def draw_pixbuf(pixbuf, cr, img_height, img_width, pb_scale, x, y)
58
+ case style
59
+ when 'retro'
60
+ Morandi::CairoExt.rounded_rectangle(cr, x, y,
61
+ img_width + x - (border_width * 2),
62
+ img_height + y - (border_width * 2), border_width)
63
+ when 'square'
64
+ cr.rectangle(x, y, img_width - (border_width * 2), img_height - (border_width * 2))
65
+ end
66
+ cr.clip
67
+
68
+ if @shrink
69
+ cr.translate(border_width, border_width)
70
+ cr.scale(pb_scale, pb_scale)
71
+ end
72
+ cr.set_source_pixbuf(pixbuf)
73
+ cr.rectangle(0, 0, pixbuf.width, pixbuf.height)
74
+
75
+ cr.paint(1.0)
76
+ end
77
+
78
+ def draw_background(cr, img_height, img_width)
79
+ cr.save do
80
+ cr.translate(-@crop[0], -@crop[1]) if @crop && ((@crop[0]).negative? || (@crop[1]).negative?)
81
+
82
+ cr.save do
83
+ cr.set_operator :source
84
+ cr.set_source_rgb 1, 1, 1
85
+ cr.paint
86
+
87
+ cr.rectangle(0, 0, img_width, img_height)
88
+ case colour
89
+ when 'retro'
90
+ cr.set_source_rgb 1, 1, 0.8
91
+ when 'black'
92
+ cr.set_source_rgb 0, 0, 0
93
+ else
94
+ cr.set_source_rgb 1, 1, 1
95
+ end
96
+ cr.fill
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'morandi/image_operation'
4
+
5
+ module Morandi
6
+ module Operation
7
+ # Straighten operation
8
+ # Does a small (ie. not 90,180,270 deg) rotation and zooms to avoid cropping
9
+ # @!visibility private
10
+ class Straighten < ImageOperation
11
+ attr_accessor :angle
12
+
13
+ def call(pixbuf)
14
+ return pixbuf if angle.zero?
15
+
16
+ rotation_value_rad = angle * (Math::PI / 180)
17
+
18
+ ratio = pixbuf.width.to_f / pixbuf.height
19
+ rh = pixbuf.height / ((ratio * Math.sin(rotation_value_rad.abs)) + Math.cos(rotation_value_rad.abs))
20
+ scale = pixbuf.height / rh.to_f.abs
21
+
22
+ a_ratio = pixbuf.height.to_f / pixbuf.width
23
+ a_rh = pixbuf.width / ((a_ratio * Math.sin(rotation_value_rad.abs)) + Math.cos(rotation_value_rad.abs))
24
+ a_scale = pixbuf.width / a_rh.to_f.abs
25
+
26
+ scale = a_scale if a_scale > scale
27
+
28
+ create_pixbuf_from_image_surface(:rgb24, pixbuf.width, pixbuf.height) do |cr|
29
+ cr.translate(pixbuf.width / 2.0, pixbuf.height / 2.0)
30
+ cr.rotate(rotation_value_rad)
31
+ cr.scale(scale, scale)
32
+ cr.translate(pixbuf.width / -2.0, pixbuf.height / - 2.0)
33
+ cr.set_source_pixbuf(pixbuf)
34
+
35
+ cr.rectangle(0, 0, pixbuf.width, pixbuf.height)
36
+ cr.paint(1.0)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Morandi
4
+ module Operation
5
+ # Straighten operation
6
+ # Does a small (ie. not 90,180,270 deg) rotation and zooms to avoid cropping
7
+ # @!visibility private
8
+ class VipsStraighten < ImageOperation
9
+ # Colour for filling background post-rotation. It can bleed into the edge pixels during resize.
10
+ # Setting it to gray minimises the average impact
11
+ ROTATION_BACKGROUND_FILL_COLOUR = 127
12
+ ROTATION_BACKGROUND_FILL_ALPHA = 255
13
+
14
+ def self.rotation_background_fill_colour(channels_count:, alpha:)
15
+ return [ROTATION_BACKGROUND_FILL_COLOUR] * channels_count unless alpha # Eg [127, 127, 127] for RGB
16
+
17
+ # Eg [127, 127, 127, 255] for RGBA
18
+ ([ROTATION_BACKGROUND_FILL_COLOUR] * (channels_count - 1)) + [ROTATION_BACKGROUND_FILL_ALPHA]
19
+ end
20
+
21
+ attr_accessor :angle
22
+
23
+ def call(img)
24
+ return img if angle.zero?
25
+
26
+ original_width = img.width
27
+ original_height = img.height
28
+
29
+ # It is possible to first rotate, then fetch width/height of resulting image to calculate scale,
30
+ # but that would make us lose precision which degrades cropping accuracy
31
+ rotation_value_rad = angle * (Math::PI / 180)
32
+ post_rotation_bounding_box_width = (img.height.to_f * Math.sin(rotation_value_rad).abs) +
33
+ (img.width.to_f * Math.cos(rotation_value_rad).abs)
34
+ post_rotation_bounding_box_height = (img.width.to_f * Math.sin(rotation_value_rad).abs) +
35
+ (img.height.to_f * Math.cos(rotation_value_rad).abs)
36
+
37
+ # Calculate scaling required to fit the original width/height within rotated image without including background
38
+ scale = [post_rotation_bounding_box_width / original_width,
39
+ post_rotation_bounding_box_height / original_height].max
40
+
41
+ background_fill_colour = self.class.rotation_background_fill_colour(channels_count: img.bands,
42
+ alpha: img.has_alpha?)
43
+ img = img.similarity(angle: angle, scale: scale, background: background_fill_colour)
44
+
45
+ # Better precision than img.width/img.height due to fractions preservation
46
+ post_scale_bounding_box_width = post_rotation_bounding_box_width * scale
47
+ post_scale_bounding_box_height = post_rotation_bounding_box_height * scale
48
+
49
+ width_diff = post_scale_bounding_box_width - original_width
50
+ height_diff = post_scale_bounding_box_height - original_height
51
+
52
+ # Round to nearest integer to reduce risk of ROTATION_BACKGROUND_FILL_COLOUR being visible in the corner
53
+ crop_x = (width_diff / 2).round
54
+ crop_y = (height_diff / 2).round
55
+
56
+ img.crop(crop_x, crop_y, original_width, original_height)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -10,6 +10,7 @@ module GdkPixbuf
10
10
  GdkPixbufCairo.pixbuf_to_surface(self)
11
11
  end
12
12
 
13
+ # Proportionally scales down the image so that it fits within max_size*max_size square
13
14
  def scale_max(max_size, interp = GdkPixbuf::InterpType::BILINEAR, _max_scale = 1.0)
14
15
  mul = (max_size / [width, height].max.to_f)
15
16
  mul = [1.0, mul].min
@@ -1,61 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'gdk_pixbuf2'
4
+ require 'morandi/srgb_conversion'
4
5
 
5
6
  module Morandi
6
7
  # ProfiledPixbuf is a descendent of GdkPixbuf::Pixbuf with ICC support.
7
8
  # It attempts to load an image using jpegicc/littlecms to ensure that it is sRGB.
9
+ # NOTE: pixbuf supports colour profiles, but it requires an explicit icc-profile option to embed it when saving file
8
10
  class ProfiledPixbuf < GdkPixbuf::Pixbuf
9
- def valid_jpeg?(filename)
10
- return false unless File.exist?(filename)
11
- return false unless File.size(filename).positive?
12
-
13
- type, = GdkPixbuf::Pixbuf.get_file_info(filename)
14
-
15
- type && type.name.eql?('jpeg')
16
- rescue StandardError
17
- false
18
- end
19
-
20
- # TODO: this doesn't use lcms
21
- def self.from_string(string, loader: nil, chunk_size: 4096)
22
- loader ||= GdkPixbuf::PixbufLoader.new
23
- ((string.bytesize + chunk_size - 1) / chunk_size).times do |i|
24
- loader.write(string.byteslice(i * chunk_size, chunk_size))
25
- end
26
- loader.close
27
- loader.pixbuf
28
- end
29
-
30
- def self.default_icc_path(path)
31
- "#{path}.icc.jpg"
32
- end
33
-
34
- def initialize(file, local_options, scale_to = nil)
11
+ def initialize(path, local_options, max_size_px = nil)
35
12
  @local_options = local_options
36
- @file = file
37
13
 
38
- if suitable_for_jpegicc?
39
- icc_file = icc_cache_path
40
- valid_jpeg?(icc_file) || system('jpgicc', '-q97', @file, icc_file)
41
- file = icc_file if valid_jpeg?(icc_file)
42
- end
14
+ path = srgb_path(path) || path
43
15
 
44
- if scale_to
45
- super(file: file, width: scale_to, height: scale_to)
16
+ if max_size_px
17
+ super(file: path, width: max_size_px, height: max_size_px)
46
18
  else
47
- super(file: file)
19
+ super(file: path)
48
20
  end
49
21
  end
50
22
 
51
23
  private
52
24
 
53
- def suitable_for_jpegicc?
54
- valid_jpeg?(@file)
55
- end
56
-
57
- def icc_cache_path
58
- @local_options['path.icc'] || Morandi::ProfiledPixbuf.default_icc_path(@file)
25
+ def srgb_path(original_path)
26
+ Morandi::SrgbConversion.perform(original_path, target_path: @local_options['path.icc'])
59
27
  end
60
28
  end
61
29
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gdk_pixbuf2'
4
+
5
+ module Morandi
6
+ # Converts the file under `path` to sRGB colour space
7
+ class SrgbConversion
8
+ # Performs a conversion to srgb colour space if possible
9
+ # Returns a path to converted file on success or nil on failure
10
+ def self.perform(path, target_path: nil)
11
+ return unless suitable_for_jpegicc?(path)
12
+
13
+ icc_file_path = target_path || default_icc_path(path)
14
+ return icc_file_path if valid_jpeg?(icc_file_path)
15
+
16
+ system('jpgicc', '-q97', path, icc_file_path, out: '/dev/null', err: '/dev/null')
17
+
18
+ return unless valid_jpeg?(icc_file_path)
19
+
20
+ icc_file_path
21
+ end
22
+
23
+ def self.default_icc_path(path)
24
+ "#{path}.icc.jpg"
25
+ end
26
+
27
+ def self.valid_jpeg?(path)
28
+ return false unless File.exist?(path)
29
+ return false unless File.size(path).positive?
30
+
31
+ type, = GdkPixbuf::Pixbuf.get_file_info(path)
32
+
33
+ type && type.name.eql?('jpeg')
34
+ rescue StandardError
35
+ false
36
+ end
37
+
38
+ def self.suitable_for_jpegicc?(path)
39
+ valid_jpeg?(path)
40
+ end
41
+ end
42
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Morandi
4
- VERSION = '0.99.03'
4
+ VERSION = '0.100.0'
5
5
  end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'vips'
4
+
5
+ require 'morandi/srgb_conversion'
6
+ require 'morandi/operation/vips_straighten'
7
+
8
+ module Morandi
9
+ # An alternative to ImageProcessor which is based on libvips for concurrent and less memory-intensive processing
10
+ class VipsImageProcessor
11
+ # Colour filter related constants
12
+ RGB_LUMINANCE_EXTRACTION_FACTORS = [0.3086, 0.6094, 0.0820].freeze
13
+ SEPIA_MODIFIER = [25, 5, -25].freeze
14
+ BLUETONE_MODIFIER = [-10, 5, 25].freeze
15
+ COLOUR_FILTER_MODIFIERS = {
16
+ 'sepia' => SEPIA_MODIFIER,
17
+ 'bluetone' => BLUETONE_MODIFIER
18
+ }.freeze
19
+ SUPPORTED_FILTERS = COLOUR_FILTER_MODIFIERS.keys + ['greyscale']
20
+
21
+ def self.supports?(input, options)
22
+ return false unless input.is_a?(String)
23
+ return false if options['brighten'].to_f != 0
24
+ return false if options['contrast'].to_f != 0
25
+ return false if options['sharpen'].to_f != 0
26
+ return false if options['redeye']&.any?
27
+ return false if options['border-style']
28
+ return false if options['background-style']
29
+
30
+ true
31
+ end
32
+
33
+ # Vips options are global, this method sets them for yielding, then restores to original
34
+ def self.with_global_options(cache_max:, concurrency:)
35
+ previous_cache_max = Vips.cache_max
36
+ previous_concurrency = Vips.concurrency
37
+
38
+ Vips.cache_set_max(cache_max)
39
+ Vips.concurrency_set(concurrency)
40
+
41
+ yield
42
+ ensure
43
+ Vips.cache_set_max(previous_cache_max)
44
+ Vips.concurrency_set(previous_concurrency)
45
+ end
46
+
47
+ def initialize(path, user_options)
48
+ @path = path
49
+
50
+ @options = user_options
51
+
52
+ @size_limit_on_load_px = @options['output.max']
53
+ @output_width = @options['output.width']
54
+ @output_height = @options['output.height']
55
+ end
56
+
57
+ def process!
58
+ source_file_path = Morandi::SrgbConversion.perform(@path) || @path
59
+ begin
60
+ @img = Vips::Image.new_from_file(source_file_path)
61
+ rescue Vips::Error => e
62
+ # Match the known errors
63
+ raise UnknownTypeError if /is not a known file format/.match?(e.message)
64
+ raise CorruptImageError if /Premature end of JPEG file/.match?(e.message)
65
+
66
+ # Re-raise generic Error when unknown
67
+ raise Error, e.message
68
+ end
69
+ if @size_limit_on_load_px
70
+ @scale = @size_limit_on_load_px.to_f / [@img.width, @img.height].max
71
+ @img = @img.resize(@scale) if not_equal_to_one(@scale)
72
+ else
73
+ @scale = 1.0
74
+ end
75
+
76
+ apply_gamma!
77
+ apply_rotate!
78
+ apply_crop!
79
+ apply_filters!
80
+
81
+ if @options['output.limit'] && @output_width && @output_height
82
+ scale_factor = [@output_width, @output_height].max.to_f / [@img.width, @img.height].max
83
+ @img = @img.resize(scale_factor) if scale_factor < 1.0
84
+ end
85
+
86
+ strip_alpha!
87
+ ensure_srgb!
88
+ end
89
+
90
+ def write_to_png(_write_to, _orientation = :any)
91
+ raise 'not implemented'
92
+ end
93
+
94
+ def write_to_jpeg(target_path, quality = nil)
95
+ process!
96
+
97
+ quality ||= @options.fetch('quality', 97)
98
+
99
+ target_path_jpg = "#{target_path}.jpg" # Vips chooses format based on file extension, this ensures jpg
100
+ @img.write_to_file(target_path_jpg, Q: quality)
101
+ FileUtils.mv(target_path_jpg, target_path)
102
+ end
103
+
104
+ private
105
+
106
+ # Remove the alpha channel if present. Vips supports alpha, but the current Pixbuf processor happens to strip it in
107
+ # most cases (straighten and cropping beyond image bounds are exceptions)
108
+ #
109
+ # Alternatively, alpha can be left intact for more accurate processing and transparent output or merged into an
110
+ # image using Vips::Image#flatten for less resource-intensive processing
111
+ def strip_alpha!
112
+ @img = @img.extract_band(0, n: @img.bands - 1) if @img.has_alpha?
113
+ end
114
+
115
+ def apply_gamma!
116
+ return unless @options['gamma'] && not_equal_to_one(@options['gamma'])
117
+
118
+ @img = @img.gamma(exponent: @options['gamma'])
119
+ end
120
+
121
+ def angle
122
+ @options['angle'].to_i % 360
123
+ end
124
+
125
+ def apply_rotate!
126
+ @img = case angle
127
+ when 0 then @img
128
+ when 90 then @img.rot90
129
+ when 180 then @img.rot180
130
+ when 270 then @img.rot270
131
+ else raise('"angle" option only accepts multiples of 90')
132
+ end
133
+
134
+ unless @options['straighten'].to_f.zero?
135
+ @img = Morandi::Operation::VipsStraighten.new_from_hash(angle: @options['straighten'].to_f).call(@img)
136
+ end
137
+
138
+ @image_width = @img.width
139
+ @image_height = @img.height
140
+ end
141
+
142
+ def apply_crop!
143
+ crop = @options['crop']
144
+
145
+ return if crop.nil? && @options['image.auto-crop'].eql?(false)
146
+
147
+ crop = crop.split(',').map(&:to_i) if crop.is_a?(String) && crop =~ /^\d+,\d+,\d+,\d+/
148
+
149
+ crop = nil unless crop.is_a?(Array) && crop.size.eql?(4) && crop.all? do |i|
150
+ i.is_a?(Numeric)
151
+ end
152
+ # can't crop, won't crop
153
+ return if @output_width.nil? && @output_height.nil? && crop.nil?
154
+
155
+ crop = crop.map { |s| (s.to_f * @scale).floor } if crop && not_equal_to_one(@scale)
156
+ crop ||= Morandi::CropUtils.autocrop_coords(@img.width, @img.height, @output_width, @output_height)
157
+ @img = Morandi::CropUtils.apply_crop_vips(@img, crop[0], crop[1], crop[2], crop[3])
158
+ end
159
+
160
+ def apply_filters!
161
+ filter_name = @options['fx']
162
+ return unless SUPPORTED_FILTERS.include?(filter_name)
163
+
164
+ # The filter-related constants assume RGB colourspace, so it requires early conversion
165
+ ensure_srgb!
166
+
167
+ # Convert to greyscale using weights
168
+ rgb_factors = RGB_LUMINANCE_EXTRACTION_FACTORS
169
+ recombination_matrix = [rgb_factors, rgb_factors, rgb_factors]
170
+ if @img.has_alpha?
171
+ # Add "0" multiplier for alpha to ignore it for luminance calculation
172
+ recombination_matrix = recombination_matrix.map { |channel_multipliers| channel_multipliers + [0] }
173
+ # Add fourth row in the matrix to preserve unchanged alpha channel
174
+ recombination_matrix << [0, 0, 0, 1]
175
+ end
176
+ @img = @img.recomb(recombination_matrix)
177
+
178
+ return unless COLOUR_FILTER_MODIFIERS[filter_name]
179
+
180
+ # Apply colour adjustment based on the modifiers setup
181
+ colour_filter_modifier = COLOUR_FILTER_MODIFIERS[filter_name]
182
+ colour_filter_modifier += [0] if @img.has_alpha?
183
+ @img = @img.linear(1.0, colour_filter_modifier)
184
+ end
185
+
186
+ def not_equal_to_one(float)
187
+ (float - 1.0).abs >= Float::EPSILON
188
+ end
189
+
190
+ def ensure_srgb!
191
+ @img = @img.colourspace(:srgb) unless @img.interpretation == :srgb
192
+ end
193
+ end
194
+ end
data/lib/morandi.rb CHANGED
@@ -5,8 +5,9 @@ require 'morandi_native'
5
5
 
6
6
  require 'morandi/cairo_ext'
7
7
  require 'morandi/pixbuf_ext'
8
+ require 'morandi/errors'
8
9
  require 'morandi/image_processor'
9
- require 'morandi/image_operation'
10
+ require 'morandi/vips_image_processor'
10
11
  require 'morandi/redeye'
11
12
  require 'morandi/crop_utils'
12
13
 
@@ -14,29 +15,53 @@ require 'morandi/crop_utils'
14
15
  module Morandi
15
16
  module_function
16
17
 
17
- # The main entry point for the libray
18
+ # The main entry point for the library
18
19
  #
19
- # @param in_file [String|GdkPixbuf::Pixbuf] source image
20
- # @param settings [Hash]
21
- # @param out_file [String] target location for image
22
- # @param local_options [Hash]
23
- #
24
- # Settings Key | Values | Description
25
- # -------------|--------|---------------
26
- # brighten | Integer -20..20 | Change image brightness
27
- # gamma | Float | Gamma correct image
28
- # contrast | Integer -20..20 | Change image contrast
29
- # sharpen | Integer -5..5 | Sharpen / Blur (negative value)
30
- # redeye | Array[[Integer,Integer],...] | Apply redeye correction at point
31
- # angle | Integer 0,90,180,270 | Rotate image
32
- # crop | Array[Integer,Integer,Integer,Integer] | Crop image
33
- # fx | String greyscale,sepia,bluetone | Apply colour filters
34
- # border-style | String square,retro | Set border style
35
- # background-style | String retro,black,white | Set border colour
36
- # quality | String '1'..'100' | Set JPG compression value, defaults to 97%
37
- def process(file_in, options, file_out, local_options = {})
38
- pro = ImageProcessor.new(file_in, options, local_options)
39
- pro.result
40
- pro.write_to_jpeg(file_out)
20
+ # @param source [String|GdkPixbuf::Pixbuf] source image
21
+ # @param [Hash] options The options describing expected processing to perform
22
+ # @option options [Integer] 'brighten' Change image brightness (-20..20)
23
+ # @option options [Float] 'gamma' Gamma correct image
24
+ # @option options [Integer] 'contrast' Change image contrast (-20..20)
25
+ # @option options [Integer] 'sharpen' Sharpen (1..5) / Blur (-1..-5)
26
+ # @option options [Integer] 'straighten' Rotate by a small angle (in degrees) and zoom in to fill the size
27
+ # @option options [Array[[Integer,Integer],...]] 'redeye' Apply redeye correction at point
28
+ # @option options [Integer] 'angle' Rotate image clockwise by multiple of 90 degrees (0, 90, 180, 270)
29
+ # @option options [Array[Integer,Integer,Integer,Integer]] 'crop' Crop image (x, y, width, height)
30
+ # @option options [String] 'fx' Apply colour filters ('greyscale', 'sepia', 'bluetone')
31
+ # @option options [String] 'border-style' Set border style ('square', 'retro')
32
+ # @option options [String] 'background-style' Set border colour ('retro', 'black', 'white')
33
+ # @option options [Integer] 'quality' (97) Set JPG compression value (1 to 100)
34
+ # @option options [Integer] 'output.max' Downscales the image to fit within the square of given size before
35
+ # processing to limit the required resources
36
+ # @option options [Integer] 'output.width' Sets desired width of resulting image
37
+ # @option options [Integer] 'output.height' Sets desired height of resulting image
38
+ # @option options [TrueClass|FalseClass] 'image.auto-crop' (true) If the output dimensions are set and this is true,
39
+ # image is cropped automatically to the desired
40
+ # dimensions.
41
+ # @option options [TrueClass|FalseClass] 'output.limit' (false) If the output dimensions are defined and this is true,
42
+ # the output image is scaled down to fit within square of
43
+ # size of the longer edge (ignoring shorter dimension!)
44
+ # @param target_path [String] target location for image
45
+ # @param local_options [Hash] Hash of options other than desired transformations
46
+ # @option local_options [String] 'path.icc' A path to store the input after converting to sRGB colour space
47
+ # @option local_options [String] 'processor' ('pixbuf') Name of the image processing library ('pixbuf', 'vips')
48
+ # NOTE: vips processor only handles subset of operations,
49
+ # see `Morandi::VipsImageProcessor.supports?` for details
50
+ def process(source, options, target_path, local_options = {})
51
+ case local_options['processor']
52
+ when 'vips'
53
+ raise(ArgumentError, 'Requested unsupported Vips operation') unless VipsImageProcessor.supports?(source, options)
54
+
55
+ # Cache saves time in expense of RAM when performing the same processing multiple times
56
+ # Cache is also created for files based on their names, which can lead to leaking files data, so in terms
57
+ # of security it feels prudent to disable it. Latest libvips supports "revalidate" option to prevent that risk
58
+ cache_max = 0
59
+ concurrency = 2 # Hardcoding to 2 for now to maintain some balance between resource usage and performance
60
+ VipsImageProcessor.with_global_options(cache_max: cache_max, concurrency: concurrency) do
61
+ VipsImageProcessor.new(source, options).write_to_jpeg(target_path)
62
+ end
63
+ else
64
+ ImageProcessor.new(source, options, local_options).tap(&:result).write_to_jpeg(target_path)
65
+ end
41
66
  end
42
67
  end
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: morandi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.99.03
4
+ version: 0.100.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - |+
@@ -11,7 +11,7 @@ authors:
11
11
  autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
- date: 2024-09-19 00:00:00.000000000 Z
14
+ date: 2025-01-17 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: atk
@@ -97,6 +97,20 @@ dependencies:
97
97
  - - "~>"
98
98
  - !ruby/object:Gem::Version
99
99
  version: '1.2'
100
+ - !ruby/object:Gem::Dependency
101
+ name: ruby-vips
102
+ requirement: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ type: :runtime
108
+ prerelease: false
109
+ version_requirements: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
100
114
  description: Apply simple edits to images
101
115
  email:
102
116
  - git@intersect-uk.co.uk
@@ -122,12 +136,19 @@ files:
122
136
  - lib/morandi.rb
123
137
  - lib/morandi/cairo_ext.rb
124
138
  - lib/morandi/crop_utils.rb
139
+ - lib/morandi/errors.rb
125
140
  - lib/morandi/image_operation.rb
126
141
  - lib/morandi/image_processor.rb
142
+ - lib/morandi/operation/colourify.rb
143
+ - lib/morandi/operation/image_border.rb
144
+ - lib/morandi/operation/straighten.rb
145
+ - lib/morandi/operation/vips_straighten.rb
127
146
  - lib/morandi/pixbuf_ext.rb
128
147
  - lib/morandi/profiled_pixbuf.rb
129
148
  - lib/morandi/redeye.rb
149
+ - lib/morandi/srgb_conversion.rb
130
150
  - lib/morandi/version.rb
151
+ - lib/morandi/vips_image_processor.rb
131
152
  - lib/morandi_native.so
132
153
  homepage: https://github.com/livelink/morandi-rb
133
154
  licenses:
@@ -143,7 +164,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
143
164
  requirements:
144
165
  - - ">="
145
166
  - !ruby/object:Gem::Version
146
- version: '2.0'
167
+ version: '2.7'
147
168
  required_rubygems_version: !ruby/object:Gem::Requirement
148
169
  requirements:
149
170
  - - ">="