retouch 0.1.0 → 0.3.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: 79c42e7a96d4184640a68bfd3205bf2d89c99bae8849f5c7e77fafd258a97738
4
- data.tar.gz: 99fd548525bcf5374f83a559746061a4e6eb33aca77679f15e21a6b3ae4d913e
3
+ metadata.gz: ff53983f255f2168fd0897271270862882583dfd8094a09195a10cffda795f1c
4
+ data.tar.gz: ebda0e130e1208ecb6389e90359edda86cb12860db5da3db125269cc8e380962
5
5
  SHA512:
6
- metadata.gz: 6187dc735d131af9ad3e6dc57f4cdb4fac3eb3f6787804a0498da4fbb21aafb8bb571b5edbc98eb4dec242c8c66d805eedcec03f4326d50e0b39f72f68638154
7
- data.tar.gz: b11da4111c899723b42cadf4cc87196bf86f4cb83d39c7ec506f9d08010a2b96ff8df6db8da04b4a5fae7885c702c2c6c77c3d0138b4d48f5d893fd700d06a69
6
+ metadata.gz: 0b9a040916e1370d2e76eec0e8252419d83fbc16686901b032550374844341d05c502bb9d387c1e521b735099ef607389c1220e3a331883d61b4db9cade10ee2
7
+ data.tar.gz: f188f38de764c5af1dc262bb7c7705086d77782ba3070e837d93f73c1362925df26848526acf26e01cac2ba8f7913d7c6405a0764d71153ef1631b7c09ce24fc
data/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  All notable changes to Retouch are recorded here.
4
4
 
5
- ## [0.1.0] - 2026-09-25
5
+ ## [0.3.0] - 2026-09-25
6
6
 
7
- Initial release
7
+ ### Added
8
+
9
+ - Pure Ruby PNG, PPM, and BMP editing through lazy pipelines and a command line.
10
+ - Geometry-based resampling, color adjustments, filters, drawing, compositing, and batch tools.
11
+ - Optional Flipbook, Glyphic, and Lookalike integrations.
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2026 Yudai Takada
3
+ Copyright (c) 2026 ydah
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,56 +1,103 @@
1
- <h1 align="center">Retouch</h1>
1
+ <div align="center">
2
2
 
3
- <p align="center">Resize and reshape images with a small Ruby API and CLI.</p>
3
+ # Retouch
4
4
 
5
- <p align="center">
6
- <a href="https://github.com/rbgfx/retouch/actions/workflows/main.yml"><img src="https://github.com/rbgfx/retouch/actions/workflows/main.yml/badge.svg" alt="CI"></a>
7
- <a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%3E%3D3.1-CC342D?logo=ruby&amp;logoColor=white" alt="Ruby 3.1+"></a>
8
- <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-750014.svg" alt="MIT license"></a>
9
- </p>
5
+ Small Ruby image edits, from the terminal or a Ruby pipeline.
10
6
 
11
- Retouch uses [Tessel](https://github.com/rbgfx/tessel) to read and write PNG, PPM, and BMP images. Its lazy pipelines cover resizing, cropping, rotation, borders, padding, extending, and trimming.
7
+ [![CI](https://github.com/rbgfx/retouch/actions/workflows/main.yml/badge.svg)](https://github.com/rbgfx/retouch/actions/workflows/main.yml)
8
+ [![Gem](https://img.shields.io/gem/v/retouch)](https://rubygems.org/gems/retouch)
9
+
10
+ </div>
11
+
12
+ Retouch handles everyday image cleanup without an ImageMagick install. It reads and writes PNG, PPM, and BMP through [Tessel](https://github.com/rbgfx/tessel). GIF input and GIF/APNG output, text rendering, and image diffs are available through optional [Flipbook](https://github.com/rbgfx/flipbook), [Glyphic](https://github.com/rbgfx/glyphic), and [Lookalike](https://github.com/rbgfx/lookalike) gems.
13
+
14
+ JPEG, WebP, color profiles, and very large photographic workloads are outside the current scope.
12
15
 
13
16
  ## Install
14
17
 
15
- Until the first RubyGems release, add the repository to your Gemfile:
18
+ ~~~sh
19
+ gem install retouch
20
+ ~~~
16
21
 
17
- ```ruby
18
- gem "retouch", github: "rbgfx/retouch"
19
- ```
22
+ Install optional integrations only when needed:
20
23
 
21
- ## Ruby API
24
+ ~~~sh
25
+ gem install flipbook glyphic lookalike
26
+ ~~~
22
27
 
23
- ```ruby
28
+ ## CLI
29
+
30
+ ~~~sh
31
+ retouch info screenshot.png
32
+ retouch screenshot.png resize 50% border 2 '#303846' -o small.png
33
+ retouch screenshot.png crop 800x600+120+40 text 'v1.2' --at south-east --font ./font.ttf -o crop.png
34
+ retouch sprite.png resize 400% --filter nearest -o sprite@4x.png
35
+ retouch 'shots/*.png' thumbnail 320x -o 'thumbs/{name}.png'
36
+ retouch 'frames/*.png' animate --fps 24 -o animation.gif
37
+ retouch a.png b.png c.png montage --cols 3 --gap 8 -o sheet.png
38
+ retouch diff expected.png actual.png -o diff.png
39
+ ~~~
40
+
41
+ Resize geometry follows familiar ImageMagick-style notation, with intentionally limited semantics:
42
+
43
+ | Geometry | Result |
44
+ | --- | --- |
45
+ | 800x600 | Fit inside while preserving aspect ratio |
46
+ | 800x600! | Stretch to the exact dimensions |
47
+ | 800x600^ | Scale to cover, then crop |
48
+ | 800x / x600 | Set one dimension and preserve aspect ratio |
49
+ | 50% / 50%x25% | Scale relative to source size |
50
+ | 800x600> | Shrink only |
51
+ | 800x600+10+20 | Dimensions plus crop offset |
52
+
53
+ Supported resize filters are nearest, bilinear, bicubic, and lanczos3 (default). A crop without offsets starts at the top left; --gravity selects one of nine anchors for crop, cover, overlay, and text.
54
+
55
+ Use --dry-run, --verbose, --quiet, --force, --strip, --level 0..9, and --jobs N to control batch runs. Batch templates accept {name}, {ext}, {dir}, {index}, and {index:03}. Existing output files are preserved unless --force is used; conflicting templates fail before processing.
56
+
57
+ ## Ruby
58
+
59
+ ~~~ruby
24
60
  require "retouch"
25
61
 
26
62
  Retouch.open("screenshot.png")
27
- .resize("640x")
28
- .border(1, "#303846")
63
+ .resize("50%")
64
+ .border(2, "#303846")
29
65
  .save("small.png")
30
- ```
31
66
 
32
- Pipelines defer work until `to_image` or `save` and leave the source image unchanged.
67
+ image = Retouch.open("screenshot.png")
68
+ .crop("800x600+120+40")
69
+ .grayscale
70
+ .to_image
33
71
 
34
- ## CLI
72
+ Retouch.batch("shots/*.png", to: "thumbs/{name}.png") do |image|
73
+ image.thumbnail("320x")
74
+ end
75
+ ~~~
35
76
 
36
- ```sh
37
- retouch screenshot.png resize 640x --filter lanczos3 -o small.png
38
- retouch screenshot.png crop 320x200+40+20 -o crop.png
39
- retouch screenshot.png rotate 90 -o rotated.png
40
- retouch info screenshot.png
41
- ```
77
+ Pipelines defer work until to_image or save and never mutate the source image. Available operations are resize/thumbnail/crop, flip/flop/rotate, trim/pad/extend/border, grayscale/invert/brightness/contrast/gamma/saturate/tint/opacity/quantize, blur/sharpen/pixelate, overlay/watermark/text/rect/arrow, and multi-image montage/append/spritesheet/animate/diff.
78
+
79
+ Text requires a BDF or TrueType font path (font: or RETOUCH_FONT). Without Glyphic, only text reports a missing optional dependency. GIF/APNG and perceptual diff operations similarly report their optional gem when invoked.
80
+
81
+ Pure Ruby pixel processing trades speed for easy installation. Nearest-neighbor scaling is suitable for pixel art; Lanczos, blur, and arbitrary-angle rotation cost more as image dimensions grow. Retouch does not claim ImageMagick pixel-for-pixel compatibility.
42
82
 
43
- Geometry accepts `WIDTHxHEIGHT`, `WIDTHx`, `xHEIGHT`, percentages, `!` to stretch, `^` to cover, and `>` to shrink only. Crop offsets may be positive or negative. Resize filters are `nearest`, `bilinear`, `bicubic`, and `lanczos3`.
83
+ On Ruby 4.0.6 with YJIT, a local run on a solid 1920×1080 image measured bilinear resize to 960×540 at 1.236s, Lanczos3 at 2.238s, Gaussian blur at σ=3 at 13.423s, and brightness at 0.184s. Nearest-neighbor enlargement from 256×256 to 1024×1024 took 0.071s. Treat these as reference measurements, not guarantees; Gaussian blur is currently the slow path and exceeds the design target of 3 seconds.
44
84
 
45
- Outputs are protected from accidental overwrite; use `--force` to replace one. `--dry-run`, `--verbose`, `--quiet`, `--strip`, and `--level 0..9` control a command.
85
+ ## API contracts
86
+
87
+ Inputs are file paths or Tessel::Image values with RGBA8 pixels. Transformations return a new image and leave the input untouched. Geometry, option, and frame errors raise ArgumentError; values of the wrong type raise TypeError; I/O and unavailable optional integrations raise Retouch::Error. Pipeline#save overwrites its destination, while the CLI and Retouch.batch refuse existing outputs unless --force / force: true is supplied.
88
+
89
+ Retouch supports PNG, PPM, and BMP input/output. GIF input and GIF/APNG output need Flipbook; text and montage labels need Glyphic and a BDF/TrueType font; diff needs Lookalike. JPEG, WebP, color management, and APNG input are unsupported. Pipelines are safe to reuse from independent calls; Tessel image mutability follows Tessel's contract, and --jobs uses processes where fork exists.
46
90
 
47
91
  ## Development
48
92
 
49
- ```sh
93
+ ~~~sh
50
94
  bundle install
51
95
  bundle exec rake verify
52
- ```
96
+ COVERAGE=1 COVERAGE_MIN=85 bundle exec rake test
97
+ ~~~
98
+
99
+ The verification task runs RuboCop, test-unit, and RBS validation. The README Ruby examples are exercised by the test suite.
53
100
 
54
101
  ## License
55
102
 
56
- MIT. See [LICENSE](LICENSE).
103
+ MIT
data/bench/retouch.rb CHANGED
@@ -13,7 +13,8 @@ puts "YJIT: #{RubyVM::YJIT.enabled?}"
13
13
  image = Tessel::Image.new(1920, 1080, fill: [80, 120, 160, 255])
14
14
  measure("1920x1080 -> 960x540 bilinear") { Retouch::Operations.resize(image, "960x540", filter: :bilinear) }
15
15
  measure("1920x1080 -> 960x540 lanczos3") { Retouch::Operations.resize(image, "960x540", filter: :lanczos3) }
16
- measure("1920x1080 -> 960x540 nearest") { Retouch::Operations.resize(image, "960x540", filter: :nearest) }
16
+ measure("1920x1080 blur sigma=3") { Retouch::Operations.blur(image, sigma: 3) }
17
+ measure("1920x1080 brightness") { Retouch::Operations.brightness(image, 0.1) }
17
18
  measure("256x256 -> 1024x1024 nearest") do
18
19
  Retouch::Operations.resize(Tessel::Image.new(256, 256), "400%", filter: :nearest)
19
20
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Retouch
4
+ module Batch
5
+ module_function
6
+
7
+ def run(pattern, to:, force: false, jobs: 1, level: 6, strip: false, &operation)
8
+ raise ArgumentError, "a block is required" unless operation
9
+
10
+ files = Array(pattern).flat_map { |item| File.file?(item) ? [item] : Dir.glob(item) }.uniq.sort
11
+ raise Error, "no input files matched" if files.empty?
12
+
13
+ jobs = Integer(jobs)
14
+ raise ArgumentError, "jobs must be positive" unless jobs.positive?
15
+
16
+ outputs = files.each_with_index.map { |file, index| expand(to, file, index) }
17
+ raise ArgumentError, "output template maps multiple inputs to the same file" unless outputs.uniq.length == outputs.length
18
+ raise Error, "refusing to overwrite #{outputs.find { |path| File.exist?(path) }}" unless force || outputs.none? { |path| File.exist?(path) }
19
+
20
+ work = files.zip(outputs).map { |input, output| [input, output] }
21
+ return work.map { |input, output| process(input, output, level:, strip:, &operation) } if jobs == 1 || !Process.respond_to?(:fork)
22
+
23
+ work.each_slice(jobs).flat_map do |group|
24
+ children = group.map do |input, output|
25
+ pid = Process.fork do
26
+ process(input, output, level:, strip:, &operation)
27
+ exit! 0
28
+ rescue StandardError
29
+ exit! 1
30
+ end
31
+ [pid, output]
32
+ end
33
+ children.each do |pid, output|
34
+ _, status = Process.wait2(pid)
35
+ raise Error, "batch worker failed for #{output}" unless status.success?
36
+ end
37
+ group.map(&:last)
38
+ end
39
+ end
40
+
41
+ def expand(template, path, index)
42
+ template = String(template)
43
+ name = File.basename(path, File.extname(path))
44
+ values = { "name" => name, "ext" => File.extname(path).delete_prefix("."), "dir" => File.dirname(path), "index" => index.to_s }
45
+ template.gsub(/\{(name|ext|dir|index)(?::(\d+))?\}/) do
46
+ key, width = Regexp.last_match.captures
47
+ value = values.fetch(key)
48
+ key == "index" && width ? value.rjust(width.to_i, "0") : value
49
+ end
50
+ end
51
+
52
+ def process(input, output, level:, strip:)
53
+ result = yield Retouch.open(input)
54
+ result = Retouch.from_image(result) if result.is_a?(Tessel::Image)
55
+ raise TypeError, "batch block must return a Retouch::Pipeline" unless result.is_a?(Pipeline)
56
+
57
+ FileUtils.mkdir_p(File.dirname(output)) unless File.dirname(output) == "."
58
+ result.save(output, level:, strip:)
59
+ output
60
+ end
61
+ end
62
+ end
data/lib/retouch/cli.rb CHANGED
@@ -5,7 +5,11 @@ require "optparse"
5
5
 
6
6
  module Retouch
7
7
  class CLI
8
- OPERATIONS = %w[resize thumbnail crop flip flop rotate pad extend border trim].freeze
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
12
+ ].freeze
9
13
 
10
14
  def self.run(argv, out: $stdout, err: $stderr)
11
15
  new(argv, out:, err:).run
@@ -18,39 +22,39 @@ module Retouch
18
22
  @args = argv.dup
19
23
  @out = out
20
24
  @err = err
21
- @options = { level: 6 }
25
+ @pid = Process.pid
26
+ @options = { jobs: 1, level: 6, frame: 0 }
22
27
  read_global_options
23
28
  raise ArgumentError, "PNG level must be between 0 and 9" unless @options[:level].between?(0, 9)
29
+ raise ArgumentError, "jobs must be positive" unless @options[:jobs].positive?
30
+ raise ArgumentError, "frame must not be negative" if @options[:frame].negative?
24
31
  end
25
32
 
26
33
  def run
27
34
  return help if @options[:help] || @args.empty?
28
- return info_command if @args.first == "info"
29
- return help(@args[1]) if @args.first == "help"
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"
30
45
 
31
46
  operation_at = @args.index { |token| OPERATIONS.include?(token) }
32
47
  return fail_usage("missing operation") unless operation_at
33
48
 
34
- inputs = @args.shift(operation_at)
35
- return fail_usage("exactly one input file is required") unless inputs.one?
36
- return fail_usage("an output path with -o is required") unless @options[:output]
37
-
38
- input = inputs.first
49
+ inputs = expand_inputs(@args.shift(operation_at))
39
50
  operations = parse_operations(@args)
40
- return announce("retouch #{input} #{operations.map(&:first).join(" ")} -o #{@options[:output]}") if @options[:dry_run]
41
- raise Error, "input file does not exist: #{input}" unless File.file?(input)
42
- raise Error, "refusing to overwrite #{@options[:output]}; use --force" if File.exist?(@options[:output]) && !@options[:force]
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)
43
53
 
44
- FileUtils.mkdir_p(File.dirname(@options[:output])) unless File.dirname(@options[:output]) == "."
45
- pipeline = operations.reduce(Retouch.open(input)) do |current, (name, args, options)|
46
- announce("applying #{name}") if @options[:verbose]
47
- current.public_send(name, *args, **options)
48
- end
49
- pipeline.save(@options[:output], level: @options[:level], strip: @options[:strip])
50
- announce(@options[:output])
51
- 0
52
- rescue ArgumentError, OptionParser::ParseError
53
- raise
54
+ transform(inputs, operations)
55
+ rescue ArgumentError, OptionParser::ParseError => e
56
+ @err.puts("retouch: #{e.message}")
57
+ 2
54
58
  rescue StandardError => e
55
59
  @err.puts("retouch: #{e.message}")
56
60
  @err.puts(e.backtrace.first) if @options[:verbose]
@@ -71,7 +75,9 @@ module Retouch
71
75
  when "--quiet" then @options[:quiet] = true
72
76
  when "--force" then @options[:force] = true
73
77
  when "--strip" then @options[:strip] = true
78
+ when "--jobs" then @options[:jobs] = Integer(require_value(token))
74
79
  when "--level" then @options[:level] = Integer(require_value(token))
80
+ when "--frame" then @options[:frame] = Integer(require_value(token))
75
81
  else remaining << token
76
82
  end
77
83
  end
@@ -85,6 +91,10 @@ module Retouch
85
91
  value
86
92
  end
87
93
 
94
+ def expand_inputs(inputs)
95
+ inputs.flat_map { |item| File.file?(item) ? [item] : Dir.glob(item) }.uniq.sort
96
+ end
97
+
88
98
  def parse_operations(tokens)
89
99
  operations = []
90
100
  until tokens.empty?
@@ -92,32 +102,58 @@ module Retouch
92
102
  raise ArgumentError, "unknown operation: #{name}" unless OPERATIONS.include?(name)
93
103
 
94
104
  args, options = case name
95
- when "resize", "thumbnail", "crop" then [[required(tokens, "geometry")], read_options(tokens, filter: :symbol, gravity: :symbol)]
96
- when "rotate" then [[required(tokens, name)], read_options(tokens, background: :string)]
97
- when "pad" then [[required(tokens, name)], read_options(tokens, color: :string)]
98
- when "extend" then [[required(tokens, name), required(tokens, name)], read_options(tokens, color: :string, gravity: :symbol)]
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)], {}]
99
108
  when "border" then [[required(tokens, name), tokens.first&.start_with?("#") ? tokens.shift : "#000000"], {}]
109
+ 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)]
100
120
  when "trim" then [[], read_options(tokens, fuzz: :integer, color: :string)]
101
- when "flip", "flop" then [[], {}]
121
+ when "info", "flip", "flop", "grayscale", "invert" then [[], {}]
122
+ else raise ArgumentError, "#{name} cannot be used in a pipeline"
102
123
  end
103
124
  operations << [name, args, options]
104
125
  end
105
126
  operations
106
127
  end
107
128
 
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]
133
+ end
134
+
108
135
  def read_options(tokens, specification)
109
136
  options = {}
110
- while tokens.first&.start_with?("--")
111
- option = tokens.shift.delete_prefix("--").tr("-", "_").to_sym
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
112
143
  type = specification[option]
113
144
  raise OptionParser::InvalidOption, "--#{option}" unless type
114
145
 
115
- value = required(tokens, option)
116
- options[option] = case type
117
- when :integer then Integer(value)
118
- when :symbol then value.tr("-", "_").to_sym
119
- else value
120
- end
146
+ if type == :boolean
147
+ options[option] = !disabled
148
+ else
149
+ value = required(tokens, option)
150
+ options[option] = case type
151
+ when :integer then Integer(value)
152
+ when :float then Float(value)
153
+ when :symbol then value.tr("-", "_").to_sym
154
+ else value
155
+ end
156
+ end
121
157
  end
122
158
  options
123
159
  end
@@ -130,45 +166,168 @@ module Retouch
130
166
  end
131
167
 
132
168
  def info_command
169
+ files = expand_inputs(@args)
170
+ return fail_usage("info needs an input file") if files.empty?
171
+
172
+ files.each { |path| announce(JSON.pretty_generate(path: path, **Operations.info(ImageIO.read(path, frame: @options[:frame])))) }
173
+ 0
174
+ end
175
+
176
+ def diff_command
133
177
  @args.shift
134
- return fail_usage("info expects one input file") unless @args.one?
178
+ files = expand_inputs(@args)
179
+ raise OptionParser::MissingArgument, "diff expects two image paths" unless files.length == 2
180
+
181
+ output = @options[:output] || "diff.png"
182
+ return announce("retouch diff #{files.join(" ")} -o #{output}") if @options[:dry_run]
135
183
 
136
- path = @args.first
137
- announce(JSON.pretty_generate({ path:, **Operations.info(ImageIO.read(path)) }))
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")))
188
+ announce(output)
138
189
  0
139
- rescue StandardError => e
140
- @err.puts("retouch: #{e.message}")
141
- 1
142
190
  end
143
191
 
144
- def fail_usage(message)
145
- @err.puts("retouch: #{message}")
146
- @err.puts("Run 'retouch --help' for usage.")
147
- 2
192
+ def aggregate(files, operations)
193
+ raise ArgumentError, "aggregate commands cannot be chained" unless operations.length == 1
194
+
195
+ operation = operations.first
196
+ name, _args, options = operation
197
+ images = files.map { |path| ImageIO.read(path, frame: @options[:frame]) }
198
+ 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)
203
+ 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
213
+ 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
221
+ end
222
+ end
223
+
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
238
+ 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
243
+ 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
+ end
257
+
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
148
266
  end
149
267
 
150
268
  def announce(message)
151
- @out.puts(message) unless @options[:quiet]
269
+ @out.puts(message) unless @options[:quiet] || Process.pid != @pid
152
270
  0
153
271
  end
154
272
 
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)
284
+ end
285
+
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]
290
+
291
+ FileUtils.mkdir_p(File.dirname(path)) unless File.dirname(path) == "."
292
+ end
293
+
294
+ def fail_usage(message)
295
+ @err.puts("retouch: #{message}")
296
+ @err.puts("Run 'retouch --help' for usage.")
297
+ 2
298
+ end
299
+
155
300
  def help(operation = nil)
156
301
  if operation
157
- raise ArgumentError, "unknown operation help: #{operation}" unless OPERATIONS.include?(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)
158
313
 
159
- @out.puts("retouch INPUT #{operation} [ARGUMENTS] -o OUTPUT")
314
+ @out.puts(details.fetch(operation, "retouch INPUT #{operation} [OPTIONS] -o OUTPUT"))
160
315
  return 0
161
316
  end
162
317
  @out.puts <<~HELP
163
- Retouch resizes and reshapes PNG, PPM, and BMP images.
318
+ Retouch edits PNG, PPM, and BMP images; optional integrations add GIF/APNG, text, and visual diffs.
164
319
 
165
- Usage: retouch INPUT OPERATION [ARGUMENTS] -o OUTPUT
320
+ Usage: retouch INPUT... OPERATION [ARGUMENTS] -o OUTPUT
166
321
  retouch help OPERATION
167
- retouch info INPUT
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
168
328
 
169
- Operations: resize thumbnail crop rotate flip flop pad extend border trim
170
- Options: -o, --output PATH --force --dry-run --verbose --quiet
171
- --strip --level 0..9 -h, --help
329
+ Global options: -o, --output PATH --force --dry-run --verbose --quiet
330
+ --strip --level 0..9 --jobs N --frame N -h, --help
172
331
  HELP
173
332
  0
174
333
  end
data/lib/retouch/io.rb CHANGED
@@ -4,8 +4,17 @@ module Retouch
4
4
  module ImageIO
5
5
  module_function
6
6
 
7
- def read(path)
7
+ def read(path, frame: 0)
8
+ frame = Integer(frame)
9
+ raise ArgumentError, "frame must not be negative" if frame.negative?
10
+
8
11
  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
9
18
  format = if signature.start_with?(Tessel::SIGNATURE)
10
19
  "PNG"
11
20
  elsif signature.start_with?("P3", "P6")
@@ -24,6 +33,9 @@ module Retouch
24
33
  image = Tessel::Image.from_rgba(image.width, image.height, image.bytes) if strip
25
34
  extension = File.extname(path).downcase
26
35
  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)
27
39
  when ".png"
28
40
  image.write(path, level: Integer(level))
29
41
  when ".ppm", ".pnm"
@@ -38,6 +50,12 @@ module Retouch
38
50
  raise Error, e.message, cause: e
39
51
  end
40
52
 
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
+
41
59
  def with_format(image, format)
42
60
  metadata = image.metadata.merge("format" => format)
43
61
  Tessel::Image.from_rgba(image.width, image.height, image.bytes, metadata:)