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 +4 -4
- data/CHANGELOG.md +6 -2
- data/LICENSE +1 -1
- data/README.md +77 -30
- data/bench/retouch.rb +2 -1
- data/lib/retouch/batch.rb +62 -0
- data/lib/retouch/cli.rb +213 -54
- data/lib/retouch/io.rb +19 -1
- data/lib/retouch/operations.rb +477 -16
- data/lib/retouch/pipeline.rb +6 -1
- data/lib/retouch/version.rb +1 -1
- data/lib/retouch.rb +7 -2
- data/retouch.gemspec +2 -2
- data/sig/retouch.rbs +18 -4
- metadata +10 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ff53983f255f2168fd0897271270862882583dfd8094a09195a10cffda795f1c
|
|
4
|
+
data.tar.gz: ebda0e130e1208ecb6389e90359edda86cb12860db5da3db125269cc8e380962
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
5
|
+
## [0.3.0] - 2026-09-25
|
|
6
6
|
|
|
7
|
-
|
|
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
data/README.md
CHANGED
|
@@ -1,56 +1,103 @@
|
|
|
1
|
-
<
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# Retouch
|
|
4
4
|
|
|
5
|
-
|
|
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&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
|
-
|
|
7
|
+
[](https://github.com/rbgfx/retouch/actions/workflows/main.yml)
|
|
8
|
+
[](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
|
-
|
|
18
|
+
~~~sh
|
|
19
|
+
gem install retouch
|
|
20
|
+
~~~
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
gem "retouch", github: "rbgfx/retouch"
|
|
19
|
-
```
|
|
22
|
+
Install optional integrations only when needed:
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
~~~sh
|
|
25
|
+
gem install flipbook glyphic lookalike
|
|
26
|
+
~~~
|
|
22
27
|
|
|
23
|
-
|
|
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("
|
|
28
|
-
.border(
|
|
63
|
+
.resize("50%")
|
|
64
|
+
.border(2, "#303846")
|
|
29
65
|
.save("small.png")
|
|
30
|
-
```
|
|
31
66
|
|
|
32
|
-
|
|
67
|
+
image = Retouch.open("screenshot.png")
|
|
68
|
+
.crop("800x600+120+40")
|
|
69
|
+
.grayscale
|
|
70
|
+
.to_image
|
|
33
71
|
|
|
34
|
-
|
|
72
|
+
Retouch.batch("shots/*.png", to: "thumbs/{name}.png") do |image|
|
|
73
|
+
image.thumbnail("320x")
|
|
74
|
+
end
|
|
75
|
+
~~~
|
|
35
76
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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[
|
|
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
|
-
@
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
96
|
-
when "rotate" then [[required(tokens, name)],
|
|
97
|
-
when "
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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} [
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
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:)
|