retouch 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/retouch/cli.rb CHANGED
@@ -6,10 +6,42 @@ require "optparse"
6
6
  module Retouch
7
7
  class CLI
8
8
  OPERATIONS = %w[
9
- info resize thumbnail crop flip flop rotate pad extend border trim grayscale invert brightness
10
- contrast gamma saturate tint opacity quantize blur sharpen pixelate overlay watermark text rect
11
- arrow montage append spritesheet animate diff
9
+ resize thumbnail crop flip flop rotate pad extend border trim
10
+ grayscale invert brightness contrast gamma saturate tint opacity quantize
11
+ blur sharpen pixelate overlay watermark text rect arrow
12
+ montage append spritesheet animate diff
12
13
  ].freeze
14
+ MULTI_IMAGE_OPERATIONS = %w[montage append spritesheet animate diff].freeze
15
+ OPERATION_USAGE = {
16
+ "resize" => "GEOMETRY [--filter nearest|bilinear|bicubic|lanczos3] [--gravity POSITION]",
17
+ "thumbnail" => "GEOMETRY [--filter FILTER]",
18
+ "crop" => "GEOMETRY [--gravity POSITION]",
19
+ "rotate" => "DEGREES [--background COLOR]",
20
+ "pad" => "PIXELS [--color COLOR]",
21
+ "extend" => "WIDTH HEIGHT [--color COLOR] [--gravity POSITION]",
22
+ "border" => "PIXELS [COLOR]",
23
+ "trim" => "[--color COLOR] [--fuzz 0..255]",
24
+ "brightness" => "-255..255",
25
+ "contrast" => "FACTOR",
26
+ "gamma" => "GAMMA",
27
+ "saturate" => "FACTOR",
28
+ "tint" => "COLOR [--amount 0..1]",
29
+ "opacity" => "0..1",
30
+ "quantize" => "[--colors 2..256] [--dither none|ordered|floyd-steinberg]",
31
+ "blur" => "SIGMA",
32
+ "sharpen" => "SIGMA [--amount FACTOR]",
33
+ "pixelate" => "BLOCK_SIZE",
34
+ "overlay" => "IMAGE [--x X] [--y Y] [--gravity POSITION] [--opacity 0..1] [--mode MODE]",
35
+ "watermark" => "IMAGE [composite options]",
36
+ "text" => "TEXT [--at POSITION] [--size PIXELS] [--font FILE] [--background COLOR]",
37
+ "rect" => "X Y WIDTH HEIGHT [COLOR] [--fill]",
38
+ "arrow" => "X1 Y1 X2 Y2 [COLOR] [--width PIXELS] [--head PIXELS]",
39
+ "montage" => "[--cols N] [--gap PIXELS] [--background COLOR] [--label]",
40
+ "append" => "[--direction vertical|horizontal] [--gap PIXELS]",
41
+ "spritesheet" => "[--max-width PIXELS] [--gap PIXELS]",
42
+ "animate" => "(--fps RATE | --delay SECONDS) [--no-loop]",
43
+ "diff" => "[--threshold 0..255] [--color COLOR]"
44
+ }.freeze
13
45
 
14
46
  def self.run(argv, out: $stdout, err: $stderr)
15
47
  new(argv, out:, err:).run
@@ -22,39 +54,35 @@ module Retouch
22
54
  @args = argv.dup
23
55
  @out = out
24
56
  @err = err
25
- @pid = Process.pid
26
- @options = { jobs: 1, level: 6, frame: 0 }
57
+ @options = { level: 6, jobs: 1, loop: true }
27
58
  read_global_options
28
59
  raise ArgumentError, "PNG level must be between 0 and 9" unless @options[:level].between?(0, 9)
29
60
  raise ArgumentError, "jobs must be positive" unless @options[:jobs].positive?
30
- raise ArgumentError, "frame must not be negative" if @options[:frame].negative?
31
61
  end
32
62
 
33
63
  def run
34
64
  return help if @options[:help] || @args.empty?
35
-
36
- if @args.first == "info"
37
- @args.shift
38
- return info_command
39
- end
40
- if @args.first == "help"
41
- @args.shift
42
- return help(@args.shift)
43
- end
44
- return diff_command if @args.first == "diff"
65
+ return info_command if @args.first == "info"
66
+ return help(@args[1]) if @args.first == "help"
45
67
 
46
68
  operation_at = @args.index { |token| OPERATIONS.include?(token) }
47
69
  return fail_usage("missing operation") unless operation_at
48
70
 
49
- inputs = expand_inputs(@args.shift(operation_at))
50
- operations = parse_operations(@args)
51
- return fail_usage("at least one input is required") if inputs.empty?
52
- return aggregate(inputs, operations) if %w[montage append animate spritesheet].include?(operations.first&.first)
71
+ inputs = @args.shift(operation_at)
72
+ return fail_usage("at least one input file is required") if inputs.empty?
73
+ return fail_usage("an output path with -o is required") unless @options[:output]
74
+
75
+ operation = @args.first
76
+ parsed = parse_cli_operations(@args)
77
+ return 2 unless parsed
53
78
 
54
- transform(inputs, operations)
55
- rescue ArgumentError, OptionParser::ParseError => e
56
- @err.puts("retouch: #{e.message}")
57
- 2
79
+ if MULTI_IMAGE_OPERATIONS.include?(operation)
80
+ return fail_usage("#{operation} must be the only operation in its command") unless parsed.one?
81
+
82
+ multi_image_command(inputs, operation, parsed.fetch(0))
83
+ else
84
+ image_command(inputs, parsed)
85
+ end
58
86
  rescue StandardError => e
59
87
  @err.puts("retouch: #{e.message}")
60
88
  @err.puts(e.backtrace.first) if @options[:verbose]
@@ -75,9 +103,10 @@ module Retouch
75
103
  when "--quiet" then @options[:quiet] = true
76
104
  when "--force" then @options[:force] = true
77
105
  when "--strip" then @options[:strip] = true
78
- when "--jobs" then @options[:jobs] = Integer(require_value(token))
106
+ when "--loop" then @options[:loop] = true
107
+ when "--no-loop" then @options[:loop] = false
79
108
  when "--level" then @options[:level] = Integer(require_value(token))
80
- when "--frame" then @options[:frame] = Integer(require_value(token))
109
+ when "--jobs" then @options[:jobs] = Integer(require_value(token))
81
110
  else remaining << token
82
111
  end
83
112
  end
@@ -91,10 +120,6 @@ module Retouch
91
120
  value
92
121
  end
93
122
 
94
- def expand_inputs(inputs)
95
- inputs.flat_map { |item| File.file?(item) ? [item] : Dir.glob(item) }.uniq.sort
96
- end
97
-
98
123
  def parse_operations(tokens)
99
124
  operations = []
100
125
  until tokens.empty?
@@ -102,58 +127,68 @@ module Retouch
102
127
  raise ArgumentError, "unknown operation: #{name}" unless OPERATIONS.include?(name)
103
128
 
104
129
  args, options = case name
105
- when "resize", "thumbnail", "crop" then parse_geometry_options(tokens)
106
- when "rotate", "pad", "brightness", "contrast", "gamma", "saturate", "opacity", "pixelate", "tint" then [[required(tokens, name)], {}]
107
- when "extend" then [[required(tokens, name), required(tokens, name)], {}]
108
- when "border" then [[required(tokens, name), tokens.first&.start_with?("#") ? tokens.shift : "#000000"], {}]
130
+ when "resize", "thumbnail", "crop"
131
+ [[required(tokens, "geometry")], read_options(tokens, filter: :symbol, gravity: :symbol)]
132
+ when "rotate" then [[Float(required(tokens, name))], read_options(tokens, background: :color)]
133
+ when "pad" then [[Integer(required(tokens, name))], read_options(tokens, color: :color)]
134
+ when "extend" then [[Integer(required(tokens, name)), Integer(required(tokens, name))], read_options(tokens, color: :color, gravity: :symbol)]
135
+ when "border" then [[Integer(required(tokens, name)), tokens.first&.start_with?("#") ? tokens.shift : "#000000"], {}]
136
+ when "trim" then [[], read_options(tokens, fuzz: :integer, color: :color)]
137
+ when "brightness", "contrast", "gamma", "saturate", "opacity" then [[Float(required(tokens, name))], {}]
138
+ when "tint" then [[required(tokens, "color")], read_options(tokens, amount: :float)]
109
139
  when "quantize" then [[], read_options(tokens, colors: :integer, dither: :symbol)]
110
- when "blur" then [[], read_options(tokens, sigma: :float)]
111
- when "sharpen" then [[], read_options(tokens, amount: :float, sigma: :float)]
112
- when "text" then [[required(tokens, name)], read_options(tokens, at: :symbol, size: :integer, color: :string, font: :string, background: :string, padding: :integer)]
113
- when "rect" then [[required(tokens, name)], read_options(tokens, color: :string, fill: :boolean, width: :integer)]
114
- when "arrow" then [[required(tokens, name), required(tokens, name), required(tokens, name), required(tokens, name)], read_options(tokens, color: :string, width: :float, head: :float)]
115
- when "overlay", "watermark" then [[required(tokens, name)], read_options(tokens, x: :integer, y: :integer, gravity: :symbol, opacity: :float, blend: :symbol)]
116
- when "montage" then [[], read_options(tokens, cols: :integer, gap: :integer, background: :string, label: :boolean, font: :string)]
117
- when "append" then [[], read_options(tokens, direction: :symbol, gap: :integer, background: :string)]
118
- when "spritesheet" then [[], read_options(tokens, cols: :integer, gap: :integer, background: :string)]
119
- when "animate" then [[], read_options(tokens, fps: :float, delay: :float, loop: :boolean)]
120
- when "trim" then [[], read_options(tokens, fuzz: :integer, color: :string)]
121
- when "info", "flip", "flop", "grayscale", "invert" then [[], {}]
122
- else raise ArgumentError, "#{name} cannot be used in a pipeline"
140
+ when "blur", "sharpen" then [[Float(required(tokens, name))], read_options(tokens, amount: :float)]
141
+ when "pixelate" then [[Integer(required(tokens, name))], {}]
142
+ when "overlay", "watermark"
143
+ [[ImageIO.read(required(tokens, "overlay image"))], read_options(tokens, x: :integer, y: :integer, gravity: :symbol, opacity: :float, mode: :symbol)]
144
+ when "text"
145
+ [[required(tokens, "text")], read_options(tokens, x: :integer, y: :integer, at: :symbol, size: :integer, font: :string, color: :color, background: :color, padding: :integer)]
146
+ when "rect"
147
+ [[Integer(required(tokens, "x")), Integer(required(tokens, "y")), Integer(required(tokens, "width")), Integer(required(tokens, "height")), tokens.first&.start_with?("#") ? tokens.shift : "#ffffff"], read_options(tokens, fill: :flag)]
148
+ when "arrow"
149
+ [[Integer(required(tokens, "x1")), Integer(required(tokens, "y1")), Integer(required(tokens, "x2")), Integer(required(tokens, "y2")), tokens.first&.start_with?("#") ? tokens.shift : "#ffffff"], read_options(tokens, width: :integer, head: :float)]
150
+ when "montage" then [[], read_options(tokens, cols: :integer, gap: :integer, background: :color, label: :flag)]
151
+ when "append" then [[], read_options(tokens, direction: :symbol, gap: :integer, background: :color)]
152
+ when "spritesheet" then [[], read_options(tokens, max_width: :integer, gap: :integer, background: :color)]
153
+ when "animate"
154
+ options = read_options(tokens, fps: :float, delay: :float, colors: :integer, dither: :symbol)
155
+ raise OptionParser::InvalidArgument, "use either --fps or --delay" if options.key?(:fps) && options.key?(:delay)
156
+ raise OptionParser::MissingArgument, "--fps or --delay" unless options.key?(:fps) || options.key?(:delay)
157
+
158
+ [[], options]
159
+ when "diff" then [[], read_options(tokens, threshold: :float, color: :color)]
160
+ when "grayscale", "invert", "flip", "flop" then [[], {}]
123
161
  end
124
162
  operations << [name, args, options]
125
163
  end
126
164
  operations
127
165
  end
128
166
 
129
- def parse_geometry_options(tokens)
130
- geometry = required(tokens, "geometry")
131
- options = read_options(tokens, filter: :symbol, gravity: :symbol, fuzz: :integer, color: :string)
132
- [[geometry], options]
167
+ def parse_cli_operations(tokens)
168
+ parse_operations(tokens)
169
+ rescue ArgumentError, OptionParser::ParseError => e
170
+ @err.puts("retouch: #{e.message}")
171
+ nil
133
172
  end
134
173
 
135
174
  def read_options(tokens, specification)
136
175
  options = {}
137
- loop do
138
- break unless tokens.first&.start_with?("--")
139
-
140
- token = tokens.shift.delete_prefix("--")
141
- disabled = token.start_with?("no-")
142
- option = token.delete_prefix("no-").tr("-", "_").to_sym
176
+ while tokens.first&.start_with?("--")
177
+ option = tokens.shift.delete_prefix("--").tr("-", "_").to_sym
143
178
  type = specification[option]
144
179
  raise OptionParser::InvalidOption, "--#{option}" unless type
145
180
 
146
- if type == :boolean
147
- options[option] = !disabled
148
- else
149
- value = required(tokens, option)
150
- options[option] = case type
181
+ options[option] = if type == :flag
182
+ true
183
+ else
184
+ value = required(tokens, option)
185
+ case type
151
186
  when :integer then Integer(value)
152
187
  when :float then Float(value)
153
188
  when :symbol then value.tr("-", "_").to_sym
154
189
  else value
155
190
  end
156
- end
191
+ end
157
192
  end
158
193
  options
159
194
  end
@@ -165,130 +200,96 @@ module Retouch
165
200
  value
166
201
  end
167
202
 
168
- def info_command
169
- files = expand_inputs(@args)
170
- return fail_usage("info needs an input file") if files.empty?
203
+ def image_command(inputs, operations)
204
+ expanded = expand_inputs(inputs)
205
+ return fail_usage("no input files matched") if expanded.empty?
171
206
 
172
- files.each { |path| announce(JSON.pretty_generate(path: path, **Operations.info(ImageIO.read(path, frame: @options[:frame])))) }
173
- 0
174
- end
207
+ batch = expanded.length > 1 || inputs.any? { |input| input.match?(/[?*{\[]/) } || @options[:output].include?("{")
208
+ if batch
209
+ return fail_usage("batch output must use a template such as {name}") if expanded.length > 1 && !@options[:output].include?("{")
175
210
 
176
- def diff_command
177
- @args.shift
178
- files = expand_inputs(@args)
179
- raise OptionParser::MissingArgument, "diff expects two image paths" unless files.length == 2
211
+ outputs = Batch.run(expanded, to: @options[:output], jobs: @options[:jobs], force: @options[:force], dry_run: @options[:dry_run]) do |input, output|
212
+ apply_operations(Retouch.open(input), operations).save(output, level: @options[:level], strip: @options[:strip])
213
+ end
214
+ outputs.each { |path| announce(path) } if @options[:dry_run] || !@options[:quiet]
215
+ return 0
216
+ end
180
217
 
181
- output = @options[:output] || "diff.png"
182
- return announce("retouch diff #{files.join(" ")} -o #{output}") if @options[:dry_run]
218
+ output = @options[:output]
219
+ return announce("retouch #{expanded.first} #{operations.map(&:first).join(" ")} -o #{output}") if @options[:dry_run]
220
+ raise Error, "input file does not exist: #{expanded.first}" unless File.file?(expanded.first)
221
+ raise Error, "refusing to overwrite #{output}; use --force" if File.exist?(output) && !@options[:force]
183
222
 
184
- image = Operations.diff(ImageIO.read(files[0]), ImageIO.read(files[1]))
185
- prepare_output(output)
186
- ImageIO.write(image, output, level: @options[:level], strip: @options[:strip])
187
- announce(format("diff %<ratio>.2f%% (%<pixels>s pixels)", ratio: image.metadata.fetch("diff_ratio").to_f * 100, pixels: image.metadata.fetch("diff_pixels")))
223
+ result = apply_operations(Retouch.open(expanded.first), operations)
224
+ FileUtils.mkdir_p(File.dirname(output)) unless File.dirname(output) == "."
225
+ result.save(output, level: @options[:level], strip: @options[:strip])
188
226
  announce(output)
189
- 0
190
227
  end
191
228
 
192
- def aggregate(files, operations)
193
- raise ArgumentError, "aggregate commands cannot be chained" unless operations.length == 1
229
+ def multi_image_command(inputs, name, (_operation, _arguments, options))
230
+ paths = expand_inputs(inputs)
231
+ return fail_usage("no input files matched") if paths.empty?
232
+ return fail_usage("#{name} expects at least two images") if paths.length < 2
233
+ return fail_usage("diff expects exactly two images") if name == "diff" && paths.length != 2
234
+
235
+ output = @options[:output]
236
+ manifest = spritesheet_manifest_path(output) if name == "spritesheet"
237
+ outputs = name == "spritesheet" ? [output, manifest] : [output]
238
+ return announce("retouch #{paths.join(" ")} #{name} -o #{output}") if @options[:dry_run]
239
+ if !@options[:force] && (existing = outputs.find { |path| File.exist?(path) })
240
+ raise Error, "refusing to overwrite #{existing}; use --force"
241
+ end
194
242
 
195
- operation = operations.first
196
- name, _args, options = operation
197
- images = files.map { |path| ImageIO.read(path, frame: @options[:frame]) }
243
+ FileUtils.mkdir_p(File.dirname(output)) unless File.dirname(output) == "."
244
+ images = paths.map { |path| ImageIO.read(path) }
198
245
  case name
199
- when "montage", "append"
200
- options[:labels] = files.map { |path| File.basename(path) } if name == "montage" && options[:label]
201
- result = Operations.public_send(name, images, **options)
202
- write(result)
246
+ when "montage"
247
+ options[:labels] = paths.map { |path| File.basename(path) } if options.delete(:label)
248
+ Operations.montage(images, **options).then { |image| ImageIO.write(image, output, level: @options[:level], strip: @options[:strip]) }
249
+ when "append"
250
+ Operations.append(images, **options).then { |image| ImageIO.write(image, output, level: @options[:level], strip: @options[:strip]) }
203
251
  when "spritesheet"
204
- result, map = Operations.spritesheet(images, **options)
205
- output = @options[:output] || "spritesheet.png"
206
- return announce("retouch spritesheet #{files.length} images -o #{output}") if @options[:dry_run]
207
-
208
- prepare_output(output, sidecars: [output.sub(/\.[^.]+\z/, ".json")])
209
- ImageIO.write(result, output, level: @options[:level], strip: @options[:strip])
210
- File.write(output.sub(/\.[^.]+\z/, ".json"), JSON.pretty_generate(map))
211
- announce(output)
212
- 0
252
+ image, placements = Operations.spritesheet(images, **options)
253
+ ImageIO.write(image, output, level: @options[:level], strip: @options[:strip])
254
+ File.write(manifest, JSON.pretty_generate(placements))
213
255
  when "animate"
214
- output = @options[:output] || "animation.gif"
215
- return announce("retouch animate #{files.length} frames -o #{output}") if @options[:dry_run]
216
-
217
- prepare_output(output)
218
- Operations.animate(images, output, **options)
219
- announce(output)
220
- 0
256
+ Operations.animate(images, output, **options, loop: @options[:loop])
257
+ when "diff"
258
+ result = Operations.diff(images[0], images[1], **options)
259
+ ImageIO.write(result[:image], output, level: @options[:level], strip: @options[:strip])
260
+ announce(format("%.2f%% pixels differ", result[:rate] * 100))
221
261
  end
262
+ announce(output)
222
263
  end
223
264
 
224
- def transform(files, operations)
225
- return fail_usage("an output path with -o is required") unless @options[:output]
226
-
227
- if files.length > 1
228
- outputs = files.map.with_index { |file, index| Batch.expand(@options[:output], file, index) }
229
- collision = outputs.group_by(&:itself).find { |_, same| same.length > 1 }
230
- raise ArgumentError, "output template collision: #{collision.first}" if collision
231
- raise Error, "refusing to overwrite output; use --force" if !@options[:force] && outputs.any? { |path| File.exist?(path) }
232
- return 0 if @options[:dry_run] && files.zip(outputs).each { |input, output| announce("#{output} <= #{input}") }
233
-
234
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
235
- outputs = Batch.run(files, to: @options[:output], force: @options[:force], jobs: @options[:jobs], level: @options[:level], strip: @options[:strip]) do |pipeline|
236
- pipeline = apply_operations(pipeline, operations)
237
- pipeline
265
+ def apply_operations(pipeline, operations)
266
+ operations.reduce(pipeline) do |current, (name, args, options)|
267
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC) if @options[:verbose]
268
+ result = current.public_send(name, *args, **options)
269
+ if started
270
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
271
+ announce(format("%<operation>s %<elapsed>.3fs", operation: name, elapsed:))
238
272
  end
239
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
240
- announce(format("batch completed in %<elapsed>.3fs", elapsed:)) if @options[:verbose]
241
- outputs.each { |path| announce(path) }
242
- return 0
273
+ result
243
274
  end
244
- input = files.first
245
- output = Batch.expand(@options[:output], input, 0)
246
- return announce("retouch #{input} #{operations.map(&:first).join(" ")} -o #{@options[:output]}") if @options[:dry_run]
247
- raise Error, "refusing to overwrite #{output}; use --force" if File.exist?(output) && !@options[:force]
248
-
249
- FileUtils.mkdir_p(File.dirname(output)) unless File.dirname(output) == "."
250
-
251
- pipeline = Retouch.open(input, frame: @options[:frame])
252
- pipeline = apply_operations(pipeline, operations)
253
- pipeline.save(output, level: @options[:level], strip: @options[:strip])
254
- announce(output)
255
- 0
256
275
  end
257
276
 
258
- def write(image)
259
- output = @options[:output] || "retouch.png"
260
- return announce("retouch #{output}") if @options[:dry_run]
261
-
262
- prepare_output(output)
263
- ImageIO.write(image, output, level: @options[:level], strip: @options[:strip])
264
- announce(output)
265
- 0
266
- end
267
-
268
- def announce(message)
269
- @out.puts(message) unless @options[:quiet] || Process.pid != @pid
270
- 0
277
+ def expand_inputs(inputs)
278
+ inputs.flat_map do |input|
279
+ input.match?(/[?*{\[]/) ? Dir.glob(input) : input
280
+ end
271
281
  end
272
282
 
273
- def apply_operations(pipeline, operations)
274
- image = pipeline.to_image
275
- operations.each do |name, args, options|
276
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
277
- args = args.dup
278
- args[0] = ImageIO.read(args[0]) if %w[overlay watermark].include?(name) && args[0].is_a?(String)
279
- image = Operations.apply(image, name, *args, **options)
280
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
281
- announce(format("%<name>s completed in %<elapsed>.3fs", name:, elapsed:)) if @options[:verbose]
282
- end
283
- Retouch.from_image(image)
283
+ def spritesheet_manifest_path(output)
284
+ File.extname(output).empty? ? "#{output}.json" : output.sub(%r{\.[^./]+\z}, ".json")
284
285
  end
285
286
 
286
- def prepare_output(path, sidecars: [])
287
- paths = [path, *sidecars]
288
- existing = paths.find { |candidate| File.exist?(candidate) }
289
- raise Error, "refusing to overwrite #{existing}; use --force" if existing && !@options[:force]
287
+ def info_command
288
+ @args.shift
289
+ return fail_usage("info expects one input file") unless @args.one?
290
290
 
291
- FileUtils.mkdir_p(File.dirname(path)) unless File.dirname(path) == "."
291
+ path = @args.first
292
+ announce(JSON.pretty_generate({ path:, **Operations.info(ImageIO.read(path)) }))
292
293
  end
293
294
 
294
295
  def fail_usage(message)
@@ -297,37 +298,27 @@ module Retouch
297
298
  2
298
299
  end
299
300
 
301
+ def announce(message)
302
+ @out.puts(message) unless @options[:quiet]
303
+ 0
304
+ end
305
+
300
306
  def help(operation = nil)
301
307
  if operation
302
- details = {
303
- "resize" => "retouch INPUT resize GEOMETRY [--filter nearest|bilinear|bicubic|lanczos3] [--gravity POSITION] -o OUTPUT",
304
- "crop" => "retouch INPUT crop GEOMETRY [--gravity POSITION] -o OUTPUT",
305
- "rotate" => "retouch INPUT rotate DEGREES -o OUTPUT",
306
- "border" => "retouch INPUT border WIDTH [#RRGGBB] -o OUTPUT",
307
- "blur" => "retouch INPUT blur [--sigma VALUE] -o OUTPUT",
308
- "text" => "retouch INPUT text TEXT --font FONT [--at POSITION] [--size PX] -o OUTPUT",
309
- "montage" => "retouch INPUT... montage [--cols N] [--gap PX] [--label --font FONT] -o OUTPUT",
310
- "spritesheet" => "retouch INPUT... spritesheet [--cols N] [--gap PX] -o OUTPUT"
311
- }
312
- raise ArgumentError, "unknown operation help: #{operation}" unless OPERATIONS.include?(operation) || details.key?(operation)
313
-
314
- @out.puts(details.fetch(operation, "retouch INPUT #{operation} [OPTIONS] -o OUTPUT"))
308
+ raise ArgumentError, "unknown operation help: #{operation}" unless OPERATIONS.include?(operation)
309
+
310
+ @out.puts("retouch INPUTS #{operation} #{OPERATION_USAGE.fetch(operation, "")} -o OUTPUT")
315
311
  return 0
316
312
  end
317
313
  @out.puts <<~HELP
318
- Retouch edits PNG, PPM, and BMP images; optional integrations add GIF/APNG, text, and visual diffs.
314
+ Retouch edits PNG, PPM, and BMP images with a Ruby API and CLI.
319
315
 
320
- Usage: retouch INPUT... OPERATION [ARGUMENTS] -o OUTPUT
316
+ Usage: retouch INPUT [INPUT ...] OPERATION [ARGUMENTS] -o OUTPUT
317
+ retouch info INPUT
321
318
  retouch help OPERATION
322
- retouch info INPUT...
323
- retouch diff EXPECTED ACTUAL -o OUTPUT
324
-
325
- Operations: resize thumbnail crop rotate flip flop border trim grayscale invert
326
- brightness contrast gamma saturate tint opacity quantize blur sharpen pixelate
327
- overlay watermark text rect arrow montage append spritesheet animate
328
319
 
329
- Global options: -o, --output PATH --force --dry-run --verbose --quiet
330
- --strip --level 0..9 --jobs N --frame N -h, --help
320
+ Operations: #{OPERATIONS.join(" ")}
321
+ Global: -o PATH --force --dry-run --verbose --quiet --strip --level 0..9 --jobs N --loop --no-loop
331
322
  HELP
332
323
  0
333
324
  end
data/lib/retouch/io.rb CHANGED
@@ -4,24 +4,19 @@ module Retouch
4
4
  module ImageIO
5
5
  module_function
6
6
 
7
- def read(path, frame: 0)
8
- frame = Integer(frame)
9
- raise ArgumentError, "frame must not be negative" if frame.negative?
10
-
7
+ def read(path)
11
8
  signature = File.binread(path, 8)
12
- if signature.start_with?("GIF87a", "GIF89a")
13
- require_optional("flipbook", "GIF input") { require "flipbook" }
14
- frames = Flipbook.read(path)
15
- image = frames.fetch(frame) { raise ArgumentError, "GIF frame is out of range: #{frame}" }
16
- return with_format(image, "GIF")
17
- end
18
9
  format = if signature.start_with?(Tessel::SIGNATURE)
19
10
  "PNG"
20
11
  elsif signature.start_with?("P3", "P6")
21
12
  "PPM"
22
13
  elsif signature.start_with?("BM")
23
14
  "BMP"
15
+ elsif signature.start_with?("GIF87a", "GIF89a")
16
+ "GIF"
24
17
  end
18
+ return with_format(Operations.open_gif(path, frame: 0), format) if format == "GIF"
19
+
25
20
  with_format(Tessel.read(path), format || "unknown")
26
21
  rescue Tessel::UnsupportedError => e
27
22
  raise Error, "unsupported image format for #{path}: #{e.message}", cause: e
@@ -33,9 +28,6 @@ module Retouch
33
28
  image = Tessel::Image.from_rgba(image.width, image.height, image.bytes) if strip
34
29
  extension = File.extname(path).downcase
35
30
  case extension
36
- when ".gif", ".apng"
37
- require_optional("flipbook", "GIF/APNG output") { require "flipbook" }
38
- Flipbook.write(path, [image], format: extension.delete_prefix("."), colors: 256)
39
31
  when ".png"
40
32
  image.write(path, level: Integer(level))
41
33
  when ".ppm", ".pnm"
@@ -50,12 +42,6 @@ module Retouch
50
42
  raise Error, e.message, cause: e
51
43
  end
52
44
 
53
- def require_optional(gem, feature)
54
- yield
55
- rescue LoadError => e
56
- raise Error, "#{feature} requires the #{gem} gem", cause: e
57
- end
58
-
59
45
  def with_format(image, format)
60
46
  metadata = image.metadata.merge("format" => format)
61
47
  Tessel::Image.from_rgba(image.width, image.height, image.bytes, metadata:)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Retouch
4
+ module Kernels
5
+ module_function
6
+
7
+ def quantize(image, colors: 256, dither: :none, palette: nil)
8
+ indices, palette = Tessel::Quantize.quantize(image, colors: Integer(colors), dither: dither.to_sym, palette:)
9
+ source = image.bytes
10
+ output = String.new(capacity: source.bytesize, encoding: Encoding::BINARY)
11
+ (image.width * image.height).times do |index|
12
+ red, green, blue = palette.fetch(indices.getbyte(index))
13
+ output << red << green << blue << source.getbyte((index * 4) + 3)
14
+ end
15
+ Tessel::Image.from_rgba(image.width, image.height, output, metadata: image.metadata)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Retouch
4
+ module Kernels
5
+ module Convolve
6
+ module_function
7
+
8
+ def gaussian(image, sigma)
9
+ sigma = Float(sigma)
10
+ raise ArgumentError, "sigma must be positive and at most 100" unless sigma.finite? && sigma.positive? && sigma <= 100
11
+
12
+ radius = (sigma * 3).ceil
13
+ weights = (-radius..radius).map { |offset| Math.exp(-(offset * offset) / (2 * sigma * sigma)) }
14
+ total = weights.sum
15
+ weights.map! { |weight| weight / total }
16
+ convolve_axis(convolve_axis(image, weights, horizontal: true), weights, horizontal: false)
17
+ end
18
+
19
+ def convolve_axis(image, weights, horizontal:)
20
+ width = image.width
21
+ height = image.height
22
+ source = image.bytes
23
+ output = String.new(capacity: source.bytesize, encoding: Encoding::BINARY)
24
+ axis_length = horizontal ? width : height
25
+ radius = weights.length / 2
26
+ entries = Array.new(axis_length) do |position|
27
+ weights.each_with_index.map do |weight, index|
28
+ sample = (position + index - radius).clamp(0, axis_length - 1)
29
+ [horizontal ? sample * 4 : sample * width * 4, weight]
30
+ end
31
+ end
32
+ height.times do |y|
33
+ width.times do |x|
34
+ red = green = blue = alpha = 0.0
35
+ base = horizontal ? y * width * 4 : x * 4
36
+ entries[horizontal ? x : y].each do |sample_offset, weight|
37
+ offset = base + sample_offset
38
+ sample_alpha = source.getbyte(offset + 3) / 255.0
39
+ red += source.getbyte(offset) * sample_alpha * weight
40
+ green += source.getbyte(offset + 1) * sample_alpha * weight
41
+ blue += source.getbyte(offset + 2) * sample_alpha * weight
42
+ alpha += source.getbyte(offset + 3) * weight
43
+ end
44
+ alpha_byte = alpha.round.clamp(0, 255)
45
+ if alpha_byte.positive?
46
+ output << (red * 255 / alpha).round.clamp(0, 255)
47
+ output << (green * 255 / alpha).round.clamp(0, 255)
48
+ output << (blue * 255 / alpha).round.clamp(0, 255)
49
+ else
50
+ output << "\0\0\0".b
51
+ end
52
+ output << alpha_byte
53
+ end
54
+ end
55
+ Tessel::Image.from_rgba(width, height, output, metadata: image.metadata)
56
+ end
57
+ private_class_method :convolve_axis
58
+ end
59
+ end
60
+ end