assembly-image 1.7.7 → 1.7.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+ require 'English' # see https://github.com/rubocop-hq/rubocop/issues/1747 (not #MAGA related)
5
+
6
+ module Assembly
7
+ class Image
8
+ # Creates jp2 derivatives
9
+ class Jp2Creator # rubocop:disable Metrics/ClassLength
10
+ # Create a JP2 file for the current image.
11
+ # Important note: this will not work for multipage TIFFs.
12
+ #
13
+ # @return [Assembly::Image] object containing the generated JP2 file
14
+ #
15
+ # @param [Assembly::Image] the image file
16
+ # @param [Hash] params Optional parameters specified as a hash, using symbols for options:
17
+ # * :output => path to the output JP2 file (default: mirrors the source file name and path, but with a .jp2 extension)
18
+ # * :overwrite => if set to false, an existing JP2 file with the same name won't be overwritten (default: false)
19
+ # * :tmp_folder => the temporary folder to use when creating the jp2 (default: '/tmp'); also used by imagemagick
20
+ #
21
+ # Example:
22
+ # source_img = Assembly::Image.new('/input/path_to_file.tif')
23
+ # derivative_img = source_img.create_jp2(:overwrite=>true)
24
+ # puts derivative_img.mimetype # 'image/jp2'
25
+ # puts derivative_image.path # '/input/path_to_file.jp2'
26
+ def self.create(image, params = {})
27
+ new(image, params).create
28
+ end
29
+
30
+ def initialize(image, params)
31
+ @image = image
32
+ @output_path = params.fetch(:output, image.jp2_filename)
33
+ @tmp_folder = params[:tmp_folder]
34
+ @overwrite = params[:overwrite]
35
+ @params = params
36
+ end
37
+
38
+ attr_reader :image, :output_path, :tmp_folder, :tmp_path
39
+
40
+ # @return [Assembly::Image] object containing the generated JP2 file
41
+ def create
42
+ create_jp2_checks
43
+
44
+ # Using instance variable so that can check in tests.
45
+ @tmp_path = make_tmp_tiff(tmp_folder: tmp_folder)
46
+
47
+ jp2_command = jp2_create_command(source_path: @tmp_path, output: output_path)
48
+ result = `#{jp2_command}`
49
+ unless $CHILD_STATUS.success?
50
+ # Clean up any partial result
51
+ File.delete(output_path) if File.exist?(output_path)
52
+ raise "JP2 creation command failed: #{jp2_command} with result #{result}"
53
+ end
54
+
55
+ File.delete(@tmp_path) unless @tmp_path.nil?
56
+
57
+ # create output response object, which is an Assembly::Image type object
58
+ Image.new(output_path)
59
+ end
60
+
61
+ private
62
+
63
+ def overwrite?
64
+ @overwrite
65
+ end
66
+
67
+ def jp2_create_command(source_path:, output:)
68
+ options = []
69
+ options << '-jp2_space sRGB' if image.samples_per_pixel == 3
70
+ options += KDU_COMPRESS_DEFAULT_OPTIONS
71
+ options << "Clayers=#{layers}"
72
+ "kdu_compress #{options.join(' ')} -i '#{source_path}' -o '#{output}' 2>&1"
73
+ end
74
+
75
+ # Get the number of JP2 layers to generate
76
+ def layers
77
+ pixdem = [image.width, image.height].max
78
+ ((Math.log(pixdem) / Math.log(2)) - (Math.log(96) / Math.log(2))).ceil + 1
79
+ end
80
+
81
+ KDU_COMPRESS_DEFAULT_OPTIONS = [
82
+ '-num_threads 2', # forces Kakadu to only use 2 threads
83
+ '-precise', # forces the use of 32-bit representations
84
+ '-no_weights', # minimization of the MSE over all reconstructed colour components
85
+ '-quiet', # suppress informative messages.
86
+ 'Creversible=no', # Disable reversible compression
87
+ 'Cmodes=BYPASS', #
88
+ 'Corder=RPCL', # R=resolution P=position C=component L=layer
89
+ 'Cblk=\\{64,64\\}', # code-block dimensions; 64x64 happens to also be the default
90
+ 'Cprecincts=\\{256,256\\},\\{256,256\\},\\{128,128\\}', # Precinct dimensions; 256x256 for the 2 highest resolution levels, defaults to 128x128 for the rest
91
+ 'ORGgen_plt=yes', # Insert packet length information
92
+ '-rate 1.5', # Ratio of compressed bits to the image size
93
+ 'Clevels=5' # Number of wavelet decomposition levels, or stages
94
+ ].freeze
95
+
96
+ # rubocop:disable Metrics/AbcSize
97
+ def create_jp2_checks
98
+ image.send(:check_for_file)
99
+ raise 'input file is not a valid image, or is the wrong mimetype' unless image.jp2able?
100
+
101
+ raise SecurityError, "output #{output_path} exists, cannot overwrite" if !overwrite? && File.exist?(output_path)
102
+ raise SecurityError, 'cannot recreate jp2 over itself' if overwrite? && image.mimetype == 'image/jp2' && output_path == image.path
103
+ end
104
+
105
+ # rubocop:disable Metrics/MethodLength
106
+ def profile_conversion_switch(profile, tmp_folder:)
107
+ path_to_profiles = File.join(Assembly::PATH_TO_IMAGE_GEM, 'profiles')
108
+ # eventually we may allow the user to specify the output_profile...when we do, you can just uncomment this code
109
+ # and update the tests that check for this
110
+ output_profile = 'sRGBIEC6196621' # params[:output_profile] || 'sRGBIEC6196621'
111
+ output_profile_file = File.join(path_to_profiles, "#{output_profile}.icc")
112
+
113
+ raise "output profile #{output_profile} invalid" unless File.exist?(output_profile_file)
114
+
115
+ return '' if image.profile.nil?
116
+
117
+ # if the input color profile exists, contract paths to the profile and setup the command
118
+
119
+ input_profile = profile.gsub(/[^[:alnum:]]/, '') # remove all non alpha-numeric characters, so we can get to a filename
120
+
121
+ # construct a path to the input profile, which might exist either in the gem itself or in the tmp folder
122
+ input_profile_file_gem = File.join(path_to_profiles, "#{input_profile}.icc")
123
+ input_profile_file_tmp = File.join(tmp_folder, "#{input_profile}.icc")
124
+ input_profile_file = File.exist?(input_profile_file_gem) ? input_profile_file_gem : input_profile_file_tmp
125
+
126
+ # if input profile was extracted and does not matches an existing known profile either in the gem or in the tmp folder,
127
+ # we'll issue an imagicmagick command to extract the profile to the tmp folder
128
+ unless File.exist?(input_profile_file)
129
+ input_profile_extract_command = "MAGICK_TEMPORARY_PATH=#{tmp_folder} convert '#{image.path}'[0] #{input_profile_file}" # extract profile from input image
130
+ result = `#{input_profile_extract_command} 2>&1`
131
+ raise "input profile extraction command failed: #{input_profile_extract_command} with result #{result}" unless $CHILD_STATUS.success?
132
+ # if extraction failed or we cannot write the file, throw exception
133
+ raise 'input profile is not a known profile and could not be extracted from input file' unless File.exist?(input_profile_file)
134
+ end
135
+
136
+ "-profile #{input_profile_file} -profile #{output_profile_file}"
137
+ end
138
+
139
+ # Bigtiff needs to be used if size of image exceeds 2^32 bytes.
140
+ def need_bigtiff?
141
+ image.image_data_size >= 2**32
142
+ end
143
+
144
+ def make_tmp_tiff(tmp_folder: nil)
145
+ tmp_folder ||= Dir.tmpdir
146
+ raise "tmp_folder #{tmp_folder} does not exists" unless File.exist?(tmp_folder)
147
+
148
+ # make temp tiff filename
149
+ tmp_tiff_file = Tempfile.new(['assembly-image', '.tif'], tmp_folder)
150
+ tmp_path = tmp_tiff_file.path
151
+
152
+ options = []
153
+
154
+ # Limit the amount of memory ImageMagick is able to use.
155
+ options << '-limit memory 1GiB -limit map 1GiB'
156
+
157
+ case image.samples_per_pixel
158
+ when 3
159
+ options << '-type TrueColor'
160
+ when 1
161
+ options << '-depth 8' # force the production of a grayscale access derivative
162
+ options << '-type Grayscale'
163
+ end
164
+
165
+ options << profile_conversion_switch(image.profile, tmp_folder: tmp_folder)
166
+
167
+ # The output in the covnert command needs to be prefixed by the image type. By default ImageMagick
168
+ # will assume TIFF: when the file extension is .tif/.tiff. TIFF64: Needs to be forced when image will
169
+ # exceed 2^32 bytes in size
170
+ tiff_type = need_bigtiff? ? 'TIFF64:' : ''
171
+
172
+ tiff_command = "MAGICK_TEMPORARY_PATH=#{tmp_folder} convert -quiet -compress none #{options.join(' ')} '#{image.path}[0]' #{tiff_type}'#{tmp_path}'"
173
+ result = `#{tiff_command} 2>&1`
174
+ raise "tiff convert command failed: #{tiff_command} with result #{result}" unless $CHILD_STATUS.success?
175
+
176
+ tmp_path
177
+ end
178
+ # rubocop:enable Metrics/MethodLength
179
+ # rubocop:enable Metrics/AbcSize
180
+ end
181
+ end
182
+ end
@@ -4,7 +4,7 @@
4
4
  module Assembly
5
5
  # Main Image class
6
6
  class Image
7
- # Project version number
8
- VERSION = '1.7.7'
7
+ # Gem version
8
+ VERSION = '1.7.8'
9
9
  end
10
10
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Assembly::Image::Jp2Creator do
6
+ subject(:result) { creator.create }
7
+
8
+ let(:ai) { Assembly::Image.new(input_path) }
9
+ let(:input_path) { TEST_TIF_INPUT_FILE }
10
+ let(:creator) { described_class.new(ai, output: TEST_JP2_OUTPUT_FILE) }
11
+
12
+ after do
13
+ # after each test, empty out the input and output test directories
14
+ remove_files(TEST_INPUT_DIR)
15
+ remove_files(TEST_OUTPUT_DIR)
16
+ end
17
+
18
+ context 'when given an LZW compressed RGB tif' do
19
+ before do
20
+ generate_test_image(TEST_TIF_INPUT_FILE, compress: 'lzw')
21
+ end
22
+
23
+ it 'creates the jp2 with a temp file' do
24
+ expect(File).to exist TEST_TIF_INPUT_FILE
25
+ expect(File).not_to exist TEST_JP2_OUTPUT_FILE
26
+ expect(result).to be_a_kind_of Assembly::Image
27
+ expect(result.path).to eq TEST_JP2_OUTPUT_FILE
28
+ expect(TEST_JP2_OUTPUT_FILE).to be_a_jp2
29
+
30
+ # Indicates a temp tiff was not created.
31
+ expect(creator.tmp_path).not_to be_nil
32
+ expect(result.exif.colorspace).to eq 'sRGB'
33
+ jp2 = Assembly::Image.new(TEST_JP2_OUTPUT_FILE)
34
+ expect(jp2.height).to eq 100
35
+ expect(jp2.width).to eq 100
36
+ end
37
+ end
38
+
39
+ context 'when the input file is a JPEG' do
40
+ before do
41
+ generate_test_image(TEST_JPEG_INPUT_FILE)
42
+ end
43
+
44
+ let(:input_path) { TEST_JPEG_INPUT_FILE }
45
+
46
+ it 'creates jp2 when given a JPEG' do
47
+ expect(File).to exist TEST_JPEG_INPUT_FILE
48
+ expect(File).not_to exist TEST_JP2_OUTPUT_FILE
49
+ expect(result).to be_a_kind_of Assembly::Image
50
+ expect(result.path).to eq TEST_JP2_OUTPUT_FILE
51
+ expect(TEST_JP2_OUTPUT_FILE).to be_a_jp2
52
+
53
+ # Indicates a temp tiff was created.
54
+ expect(creator.tmp_path).not_to be_nil
55
+ expect(File).not_to exist creator.tmp_path
56
+ end
57
+ end
58
+ end