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 +4 -4
- data/CHANGELOG.md +38 -5
- data/README.md +6 -23
- data/lib/gdk_pixbuf_cairo.so +0 -0
- data/lib/morandi/crop_utils.rb +34 -0
- data/lib/morandi/errors.rb +12 -0
- data/lib/morandi/image_operation.rb +0 -174
- data/lib/morandi/image_processor.rb +25 -19
- data/lib/morandi/operation/colourify.rb +45 -0
- data/lib/morandi/operation/image_border.rb +102 -0
- data/lib/morandi/operation/straighten.rb +41 -0
- data/lib/morandi/operation/vips_straighten.rb +60 -0
- data/lib/morandi/pixbuf_ext.rb +1 -0
- data/lib/morandi/profiled_pixbuf.rb +9 -41
- data/lib/morandi/srgb_conversion.rb +42 -0
- data/lib/morandi/version.rb +1 -1
- data/lib/morandi/vips_image_processor.rb +194 -0
- data/lib/morandi.rb +49 -24
- data/lib/morandi_native.so +0 -0
- metadata +24 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0ca1f4a637e59ee90de4f309dfecc40eae27b103f416ea301af36e8bd04c555f
|
4
|
+
data.tar.gz: 8ebeda3024e6275ea7e8b75c8357339523a59a24987e1ebd07142cba445edce7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
##
|
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
|
-
###
|
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
|
-
###
|
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
|
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(
|
24
|
+
Morandi.process(source, options, target_path)
|
26
25
|
````
|
27
|
-
|
28
|
-
|
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
|
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`.
|
data/lib/gdk_pixbuf_cairo.so
CHANGED
Binary file
|
data/lib/morandi/crop_utils.rb
CHANGED
@@ -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
|
@@ -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
|
-
@
|
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, @
|
88
|
-
|
89
|
-
|
90
|
-
@
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
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(
|
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(
|
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(
|
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
|
data/lib/morandi/pixbuf_ext.rb
CHANGED
@@ -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
|
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
|
-
|
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
|
45
|
-
super(file:
|
16
|
+
if max_size_px
|
17
|
+
super(file: path, width: max_size_px, height: max_size_px)
|
46
18
|
else
|
47
|
-
super(file:
|
19
|
+
super(file: path)
|
48
20
|
end
|
49
21
|
end
|
50
22
|
|
51
23
|
private
|
52
24
|
|
53
|
-
def
|
54
|
-
|
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
|
data/lib/morandi/version.rb
CHANGED
@@ -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/
|
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
|
18
|
+
# The main entry point for the library
|
18
19
|
#
|
19
|
-
# @param
|
20
|
-
# @param
|
21
|
-
# @
|
22
|
-
# @
|
23
|
-
#
|
24
|
-
#
|
25
|
-
#
|
26
|
-
#
|
27
|
-
#
|
28
|
-
#
|
29
|
-
#
|
30
|
-
#
|
31
|
-
#
|
32
|
-
#
|
33
|
-
#
|
34
|
-
#
|
35
|
-
#
|
36
|
-
#
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
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
|
data/lib/morandi_native.so
CHANGED
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.
|
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:
|
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.
|
167
|
+
version: '2.7'
|
147
168
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
148
169
|
requirements:
|
149
170
|
- - ">="
|