morandi 0.99.4 → 0.101.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: 72cf19456503d11e3729ece721a4f8cf542ec674d5c35497d53efc8d333ebbd4
4
- data.tar.gz: 3d05af03a553609b7bb4901071b675273127a329b323fb45bd1d7c071fe8aed5
3
+ metadata.gz: 82a7c6e28aeb2aae50257fa51f5eef82c7ceba3ee28604f53d875803363e582b
4
+ data.tar.gz: d7d80b7c3ba2f279b3650e9fdc105e41cd62632098e35ae66cb615c91ec8daad
5
5
  SHA512:
6
- metadata.gz: fa60e3b5ccefa7a76fa8e4f70d28f3ef1ad80e81a8f1d07c35a258e6748e9a4ce4e5923f82be682456a74c5ca8acc185030a783f925246bf04fad5776d20aed1
7
- data.tar.gz: 9bfff0edf6d1d52d2be6f836138b60d35c22a788fc337c46c7837a9b719f91ca6347ff746bd79f757737d6f6abba5615dd9f480e79ed07a8e9d67085cf6e3ad2
6
+ metadata.gz: 4ffda2485c2c6253064444eac78de3aacaab5aa46ed40998af6f37b578d98ef1883418cad26fac045fdbc251769fd60822c977bbdde6d3f536f0e1bcbd906f01
7
+ data.tar.gz: 8b785f03ee1ae2ebcfc4eb3b2b5e79c291b8596ab176208f18f7438cd57e400471847ff22949abdf35276f2b4acfb01d5771780f9d44b471d1fc4935033d457b
data/CHANGELOG.md CHANGED
@@ -4,7 +4,29 @@ 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
- ## [0.99.4] 18.11.2024
7
+ ## [0.101.0] 14.01.2026
8
+ ### Added
9
+ - [BREAKING] introduced automated cleanup of srgb files after processing
10
+
11
+ ### Removed
12
+ - [BREAKING] support for custom srgb file path
13
+
14
+ ## [0.100.0] 17.01.2024
15
+ ### Added
16
+ - Vips image processor (resizing)
17
+ - Vips colour filters
18
+ - Vips crop
19
+ - Vips rotate
20
+ - Vips straighten
21
+ - Vips gamma
22
+ - Vips stripping alpha
23
+ - Explicit error when trying to use VipsProcessor with unsupported options
24
+ - Vips cast to srgb when image uses a different colourspace
25
+
26
+ ### Removed
27
+ - [BREAKING] dropped support for a broken 'dominant' border colour
28
+
29
+ ## [0.99.4] 22.11.2024
8
30
  ### Added
9
31
  - Better test coverage for straighten operation
10
32
  - Support for visual image comparison in specs
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
@@ -126,7 +126,7 @@ module Morandi
126
126
  @pb = MorandiNative::PixbufUtils.brightness(@pb, brighten)
127
127
  end
128
128
 
129
- if options['gamma'] && not_equal_to_one(options['gamma'])
129
+ if options['gamma'] && not_equal_to_one?(options['gamma'])
130
130
  @pb = MorandiNative::PixbufUtils.gamma(@pb,
131
131
  options['gamma'])
132
132
  end
@@ -197,7 +197,7 @@ module Morandi
197
197
  # can't crop, won't crop
198
198
  return if @width.nil? && @height.nil? && crop.nil?
199
199
 
200
- crop = crop.map { |s| (s.to_f * @scale).floor } if crop && not_equal_to_one(@scale)
200
+ crop = crop.map { |s| (s.to_f * @scale).floor } if crop && not_equal_to_one?(@scale)
201
201
 
202
202
  crop ||= Morandi::CropUtils.autocrop_coords(@pb.width, @pb.height, @width, @height)
203
203
 
@@ -226,7 +226,7 @@ module Morandi
226
226
  colour ||= 'black'
227
227
 
228
228
  crop = options['crop']
229
- crop = crop.map { |s| (s.to_f * @scale).floor } if crop && not_equal_to_one(@scale)
229
+ crop = crop.map { |s| (s.to_f * @scale).floor } if crop && not_equal_to_one?(@scale)
230
230
 
231
231
  op = Morandi::Operation::ImageBorder.new_from_hash(
232
232
  'style' => style,
@@ -243,7 +243,7 @@ module Morandi
243
243
 
244
244
  private
245
245
 
246
- def not_equal_to_one(float)
246
+ def not_equal_to_one?(float)
247
247
  (float - 1.0).abs >= Float::EPSILON
248
248
  end
249
249
  end
@@ -7,7 +7,7 @@ module Morandi
7
7
  module Operation
8
8
  # Image Border operation
9
9
  # Supports retro (rounded) and square borders
10
- # Background colour (ie. border colour) can be white, black, dominant (ie. from image)
10
+ # Background colour (ie. border colour) can be white, black
11
11
  # @!visibility private
12
12
  class ImageBorder < ImageOperation
13
13
  attr_accessor :style, :colour, :crop, :size, :print_size, :shrink, :border_size
@@ -16,7 +16,7 @@ module Morandi
16
16
  return pixbuf unless %w[square retro].include? @style
17
17
 
18
18
  create_pixbuf_from_image_surface(:rgb24, pixbuf.width, pixbuf.height) do |cr|
19
- if @crop && ((@crop[0]).negative? || (@crop[1]).negative?)
19
+ if @crop && (@crop[0].negative? || @crop[1].negative?)
20
20
  img_width = size[0]
21
21
  img_height = size[1]
22
22
  else
@@ -26,7 +26,7 @@ module Morandi
26
26
 
27
27
  @border_scale = [img_width, img_height].max.to_f / print_size.max.to_i
28
28
 
29
- draw_background(cr, img_height, img_width, pixbuf)
29
+ draw_background(cr, img_height, img_width)
30
30
 
31
31
  x = border_width
32
32
  y = border_width
@@ -38,7 +38,7 @@ module Morandi
38
38
  # Should be less than 1
39
39
  pb_scale = (longest_side - (border_width * 2)) / longest_side
40
40
 
41
- if @crop && ((@crop[0]).negative? || (@crop[1]).negative?)
41
+ if @crop && (@crop[0].negative? || @crop[1].negative?)
42
42
  x -= @crop[0]
43
43
  y -= @crop[1]
44
44
  end
@@ -75,9 +75,9 @@ module Morandi
75
75
  cr.paint(1.0)
76
76
  end
77
77
 
78
- def draw_background(cr, img_height, img_width, pixbuf)
78
+ def draw_background(cr, img_height, img_width)
79
79
  cr.save do
80
- cr.translate(-@crop[0], -@crop[1]) if @crop && ((@crop[0]).negative? || (@crop[1]).negative?)
80
+ cr.translate(-@crop[0], -@crop[1]) if @crop && (@crop[0].negative? || @crop[1].negative?)
81
81
 
82
82
  cr.save do
83
83
  cr.set_operator :source
@@ -86,12 +86,6 @@ module Morandi
86
86
 
87
87
  cr.rectangle(0, 0, img_width, img_height)
88
88
  case colour
89
- when 'dominant'
90
- pixbuf.scale_max(400).save(fn = "/tmp/hist-#{$PROCESS_ID}.#{Time.now.to_i}", 'jpeg')
91
- histogram = Colorscore::Histogram.new(fn)
92
- FileUtils.rm_f(fn)
93
- col = histogram.scores.first[1]
94
- cr.set_source_rgb col.red / 256.0, col.green / 256.0, col.blue / 256.0
95
89
  when 'retro'
96
90
  cr.set_source_rgb 1, 1, 0.8
97
91
  when 'black'
@@ -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
@@ -8,22 +8,23 @@ module Morandi
8
8
  # It attempts to load an image using jpegicc/littlecms to ensure that it is sRGB.
9
9
  # NOTE: pixbuf supports colour profiles, but it requires an explicit icc-profile option to embed it when saving file
10
10
  class ProfiledPixbuf < GdkPixbuf::Pixbuf
11
- def initialize(path, local_options, max_size_px = nil)
12
- @local_options = local_options
13
-
14
- path = srgb_path(path) || path
11
+ def initialize(path, _local_options, max_size_px = nil)
12
+ srgb_converted_file_path = srgb_path(path)
13
+ path = srgb_converted_file_path || path
15
14
 
16
15
  if max_size_px
17
16
  super(file: path, width: max_size_px, height: max_size_px)
18
17
  else
19
18
  super(file: path)
20
19
  end
20
+ ensure
21
+ FileUtils.rm_f(srgb_converted_file_path) if srgb_converted_file_path
21
22
  end
22
23
 
23
24
  private
24
25
 
25
26
  def srgb_path(original_path)
26
- Morandi::SrgbConversion.perform(original_path, target_path: @local_options['path.icc'])
27
+ Morandi::SrgbConversion.perform(original_path)
27
28
  end
28
29
  end
29
30
  end
@@ -7,15 +7,16 @@ module Morandi
7
7
  class SrgbConversion
8
8
  # Performs a conversion to srgb colour space if possible
9
9
  # Returns a path to converted file on success or nil on failure
10
- def self.perform(path, target_path: nil)
10
+ def self.perform(path)
11
11
  return unless suitable_for_jpegicc?(path)
12
12
 
13
- icc_file_path = target_path || default_icc_path(path)
14
- return icc_file_path if valid_jpeg?(icc_file_path)
15
-
13
+ icc_file_path = default_icc_path(path)
16
14
  system('jpgicc', '-q97', path, icc_file_path, out: '/dev/null', err: '/dev/null')
17
15
 
18
- return unless valid_jpeg?(icc_file_path)
16
+ unless valid_jpeg?(icc_file_path)
17
+ FileUtils.rm_f(icc_file_path) # jpgicc likes to leave an empty file after failing
18
+ return
19
+ end
19
20
 
20
21
  icc_file_path
21
22
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Morandi
4
- VERSION = '0.99.4'
4
+ VERSION = '0.101.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 = @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
@@ -7,6 +7,7 @@ require 'morandi/cairo_ext'
7
7
  require 'morandi/pixbuf_ext'
8
8
  require 'morandi/errors'
9
9
  require 'morandi/image_processor'
10
+ require 'morandi/vips_image_processor'
10
11
  require 'morandi/redeye'
11
12
  require 'morandi/crop_utils'
12
13
 
@@ -22,12 +23,13 @@ module Morandi
22
23
  # @option options [Float] 'gamma' Gamma correct image
23
24
  # @option options [Integer] 'contrast' Change image contrast (-20..20)
24
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
25
27
  # @option options [Array[[Integer,Integer],...]] 'redeye' Apply redeye correction at point
26
- # @option options [Integer] 'angle' Rotate image clockwise by multiple of 90 (0, 90, 180, 270)
28
+ # @option options [Integer] 'angle' Rotate image clockwise by multiple of 90 degrees (0, 90, 180, 270)
27
29
  # @option options [Array[Integer,Integer,Integer,Integer]] 'crop' Crop image (x, y, width, height)
28
30
  # @option options [String] 'fx' Apply colour filters ('greyscale', 'sepia', 'bluetone')
29
31
  # @option options [String] 'border-style' Set border style ('square', 'retro')
30
- # @option options [String] 'background-style' Set border colour ('retro', 'black', 'white', 'dominant')
32
+ # @option options [String] 'background-style' Set border colour ('retro', 'black', 'white')
31
33
  # @option options [Integer] 'quality' (97) Set JPG compression value (1 to 100)
32
34
  # @option options [Integer] 'output.max' Downscales the image to fit within the square of given size before
33
35
  # processing to limit the required resources
@@ -41,10 +43,27 @@ module Morandi
41
43
  # size of the longer edge (ignoring shorter dimension!)
42
44
  # @param target_path [String] target location for image
43
45
  # @param local_options [Hash] Hash of options other than desired transformations
44
- # @option local_options [String] 'path.icc' A path to store the input after converting to sRGB colour space
46
+ # @option local_options [String] 'processor' ('pixbuf') Name of the image processing library ('pixbuf', 'vips')
47
+ # NOTE: vips processor only handles subset of operations,
48
+ # see `Morandi::VipsImageProcessor.supports?` for details
45
49
  def process(source, options, target_path, local_options = {})
46
- pro = ImageProcessor.new(source, options, local_options)
47
- pro.result
48
- pro.write_to_jpeg(target_path)
50
+ case local_options['processor']
51
+ when 'vips'
52
+ raise(ArgumentError, 'Requested unsupported Vips operation') unless VipsImageProcessor.supports?(source, options)
53
+
54
+ # Cache saves time in expense of RAM when performing the same processing multiple times
55
+ # Cache is also created for files based on their names, which can lead to leaking files data, so in terms
56
+ # of security it feels prudent to disable it. Latest libvips supports "revalidate" option to prevent that risk
57
+ cache_max = 0
58
+ concurrency = 2 # Hardcoding to 2 for now to maintain some balance between resource usage and performance
59
+ VipsImageProcessor.with_global_options(cache_max: cache_max, concurrency: concurrency) do
60
+ srgb_converted_file_path = Morandi::SrgbConversion.perform(source)
61
+ VipsImageProcessor.new(srgb_converted_file_path || source, options).write_to_jpeg(target_path)
62
+ ensure
63
+ FileUtils.rm_f(srgb_converted_file_path) if srgb_converted_file_path
64
+ end
65
+ else
66
+ ImageProcessor.new(source, options, local_options).tap(&:result).write_to_jpeg(target_path)
67
+ end
49
68
  end
50
69
  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.4
4
+ version: 0.101.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-11-22 00:00:00.000000000 Z
14
+ date: 2026-01-28 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
@@ -128,11 +142,13 @@ files:
128
142
  - lib/morandi/operation/colourify.rb
129
143
  - lib/morandi/operation/image_border.rb
130
144
  - lib/morandi/operation/straighten.rb
145
+ - lib/morandi/operation/vips_straighten.rb
131
146
  - lib/morandi/pixbuf_ext.rb
132
147
  - lib/morandi/profiled_pixbuf.rb
133
148
  - lib/morandi/redeye.rb
134
149
  - lib/morandi/srgb_conversion.rb
135
150
  - lib/morandi/version.rb
151
+ - lib/morandi/vips_image_processor.rb
136
152
  - lib/morandi_native.so
137
153
  homepage: https://github.com/livelink/morandi-rb
138
154
  licenses:
@@ -155,7 +171,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
155
171
  - !ruby/object:Gem::Version
156
172
  version: '0'
157
173
  requirements: []
158
- rubygems_version: 3.1.2
174
+ rubygems_version: 3.5.3
159
175
  signing_key:
160
176
  specification_version: 4
161
177
  summary: Simple Image Edits