retouch 0.1.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 +7 -0
- data/.rubocop.yml +35 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE +21 -0
- data/README.md +56 -0
- data/Rakefile +19 -0
- data/bench/retouch.rb +19 -0
- data/exe/retouch +6 -0
- data/lib/retouch/cli.rb +176 -0
- data/lib/retouch/geometry.rb +73 -0
- data/lib/retouch/gravity.rb +18 -0
- data/lib/retouch/io.rb +46 -0
- data/lib/retouch/operations.rb +264 -0
- data/lib/retouch/pipeline.rb +37 -0
- data/lib/retouch/version.rb +5 -0
- data/lib/retouch.rb +23 -0
- data/retouch.gemspec +34 -0
- data/sig/retouch.rbs +45 -0
- metadata +152 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 79c42e7a96d4184640a68bfd3205bf2d89c99bae8849f5c7e77fafd258a97738
|
|
4
|
+
data.tar.gz: 99fd548525bcf5374f83a559746061a4e6eb33aca77679f15e21a6b3ae4d913e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6187dc735d131af9ad3e6dc57f4cdb4fac3eb3f6787804a0498da4fbb21aafb8bb571b5edbc98eb4dec242c8c66d805eedcec03f4326d50e0b39f72f68638154
|
|
7
|
+
data.tar.gz: b11da4111c899723b42cadf4cc87196bf86f4cb83d39c7ec506f9d08010a2b96ff8df6db8da04b4a5fae7885c702c2c6c77c3d0138b4d48f5d893fd700d06a69
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
NewCops: enable
|
|
3
|
+
TargetRubyVersion: 3.1
|
|
4
|
+
SuggestExtensions: false
|
|
5
|
+
CacheRootDirectory: tmp/rubocop-cache
|
|
6
|
+
Exclude:
|
|
7
|
+
- vendor/**/*
|
|
8
|
+
- tmp/**/*
|
|
9
|
+
|
|
10
|
+
Layout/LineLength:
|
|
11
|
+
Max: 300
|
|
12
|
+
|
|
13
|
+
Metrics:
|
|
14
|
+
Enabled: false
|
|
15
|
+
|
|
16
|
+
Style/Documentation:
|
|
17
|
+
Enabled: false
|
|
18
|
+
|
|
19
|
+
Style/StringLiterals:
|
|
20
|
+
EnforcedStyle: double_quotes
|
|
21
|
+
|
|
22
|
+
Style/StringLiteralsInInterpolation:
|
|
23
|
+
Enabled: false
|
|
24
|
+
|
|
25
|
+
Style/Attr:
|
|
26
|
+
Enabled: false
|
|
27
|
+
|
|
28
|
+
Naming/MethodParameterName:
|
|
29
|
+
Enabled: false
|
|
30
|
+
|
|
31
|
+
Style/MultilineBlockChain:
|
|
32
|
+
Enabled: false
|
|
33
|
+
|
|
34
|
+
Gemspec/DevelopmentDependencies:
|
|
35
|
+
Enabled: false
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<h1 align="center">Retouch</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">Resize and reshape images with a small Ruby API and CLI.</p>
|
|
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&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>
|
|
10
|
+
|
|
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.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Until the first RubyGems release, add the repository to your Gemfile:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "retouch", github: "rbgfx/retouch"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Ruby API
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "retouch"
|
|
25
|
+
|
|
26
|
+
Retouch.open("screenshot.png")
|
|
27
|
+
.resize("640x")
|
|
28
|
+
.border(1, "#303846")
|
|
29
|
+
.save("small.png")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Pipelines defer work until `to_image` or `save` and leave the source image unchanged.
|
|
33
|
+
|
|
34
|
+
## CLI
|
|
35
|
+
|
|
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
|
+
```
|
|
42
|
+
|
|
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`.
|
|
44
|
+
|
|
45
|
+
Outputs are protected from accidental overwrite; use `--force` to replace one. `--dry-run`, `--verbose`, `--quiet`, `--strip`, and `--level 0..9` control a command.
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
bundle install
|
|
51
|
+
bundle exec rake verify
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
MIT. See [LICENSE](LICENSE).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
require "rbs"
|
|
6
|
+
require "rubocop/rake_task"
|
|
7
|
+
|
|
8
|
+
Rake::TestTask.new(:test) do |task|
|
|
9
|
+
task.libs << "lib"
|
|
10
|
+
task.pattern = "test/**/*_test.rb"
|
|
11
|
+
end
|
|
12
|
+
RuboCop::RakeTask.new(:lint) { |task| task.options = ["--cache", "false"] }
|
|
13
|
+
|
|
14
|
+
task :types do
|
|
15
|
+
sh "bundle exec rbs -I sig validate"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
task verify: %i[lint test types]
|
|
19
|
+
task default: :verify
|
data/bench/retouch.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "retouch"
|
|
4
|
+
|
|
5
|
+
def measure(name)
|
|
6
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
7
|
+
yield
|
|
8
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
9
|
+
puts format("%<name>s %<elapsed>.3fs", name:, elapsed:)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
puts "YJIT: #{RubyVM::YJIT.enabled?}"
|
|
13
|
+
image = Tessel::Image.new(1920, 1080, fill: [80, 120, 160, 255])
|
|
14
|
+
measure("1920x1080 -> 960x540 bilinear") { Retouch::Operations.resize(image, "960x540", filter: :bilinear) }
|
|
15
|
+
measure("1920x1080 -> 960x540 lanczos3") { Retouch::Operations.resize(image, "960x540", filter: :lanczos3) }
|
|
16
|
+
measure("1920x1080 -> 960x540 nearest") { Retouch::Operations.resize(image, "960x540", filter: :nearest) }
|
|
17
|
+
measure("256x256 -> 1024x1024 nearest") do
|
|
18
|
+
Retouch::Operations.resize(Tessel::Image.new(256, 256), "400%", filter: :nearest)
|
|
19
|
+
end
|
data/exe/retouch
ADDED
data/lib/retouch/cli.rb
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
module Retouch
|
|
7
|
+
class CLI
|
|
8
|
+
OPERATIONS = %w[resize thumbnail crop flip flop rotate pad extend border trim].freeze
|
|
9
|
+
|
|
10
|
+
def self.run(argv, out: $stdout, err: $stderr)
|
|
11
|
+
new(argv, out:, err:).run
|
|
12
|
+
rescue ArgumentError, OptionParser::ParseError => e
|
|
13
|
+
err.puts("retouch: #{e.message}")
|
|
14
|
+
2
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(argv, out:, err:)
|
|
18
|
+
@args = argv.dup
|
|
19
|
+
@out = out
|
|
20
|
+
@err = err
|
|
21
|
+
@options = { level: 6 }
|
|
22
|
+
read_global_options
|
|
23
|
+
raise ArgumentError, "PNG level must be between 0 and 9" unless @options[:level].between?(0, 9)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
return help if @options[:help] || @args.empty?
|
|
28
|
+
return info_command if @args.first == "info"
|
|
29
|
+
return help(@args[1]) if @args.first == "help"
|
|
30
|
+
|
|
31
|
+
operation_at = @args.index { |token| OPERATIONS.include?(token) }
|
|
32
|
+
return fail_usage("missing operation") unless operation_at
|
|
33
|
+
|
|
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
|
|
39
|
+
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]
|
|
43
|
+
|
|
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
|
+
rescue StandardError => e
|
|
55
|
+
@err.puts("retouch: #{e.message}")
|
|
56
|
+
@err.puts(e.backtrace.first) if @options[:verbose]
|
|
57
|
+
1
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def read_global_options
|
|
63
|
+
remaining = []
|
|
64
|
+
until @args.empty?
|
|
65
|
+
token = @args.shift
|
|
66
|
+
case token
|
|
67
|
+
when "-h", "--help" then @options[:help] = true
|
|
68
|
+
when "-o", "--output" then @options[:output] = require_value(token)
|
|
69
|
+
when "--dry-run" then @options[:dry_run] = true
|
|
70
|
+
when "--verbose" then @options[:verbose] = true
|
|
71
|
+
when "--quiet" then @options[:quiet] = true
|
|
72
|
+
when "--force" then @options[:force] = true
|
|
73
|
+
when "--strip" then @options[:strip] = true
|
|
74
|
+
when "--level" then @options[:level] = Integer(require_value(token))
|
|
75
|
+
else remaining << token
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
@args = remaining
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def require_value(option)
|
|
82
|
+
value = @args.shift
|
|
83
|
+
raise OptionParser::MissingArgument, option unless value
|
|
84
|
+
|
|
85
|
+
value
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_operations(tokens)
|
|
89
|
+
operations = []
|
|
90
|
+
until tokens.empty?
|
|
91
|
+
name = tokens.shift
|
|
92
|
+
raise ArgumentError, "unknown operation: #{name}" unless OPERATIONS.include?(name)
|
|
93
|
+
|
|
94
|
+
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)]
|
|
99
|
+
when "border" then [[required(tokens, name), tokens.first&.start_with?("#") ? tokens.shift : "#000000"], {}]
|
|
100
|
+
when "trim" then [[], read_options(tokens, fuzz: :integer, color: :string)]
|
|
101
|
+
when "flip", "flop" then [[], {}]
|
|
102
|
+
end
|
|
103
|
+
operations << [name, args, options]
|
|
104
|
+
end
|
|
105
|
+
operations
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def read_options(tokens, specification)
|
|
109
|
+
options = {}
|
|
110
|
+
while tokens.first&.start_with?("--")
|
|
111
|
+
option = tokens.shift.delete_prefix("--").tr("-", "_").to_sym
|
|
112
|
+
type = specification[option]
|
|
113
|
+
raise OptionParser::InvalidOption, "--#{option}" unless type
|
|
114
|
+
|
|
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
|
|
121
|
+
end
|
|
122
|
+
options
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def required(tokens, name)
|
|
126
|
+
value = tokens.shift
|
|
127
|
+
raise OptionParser::MissingArgument, name unless value
|
|
128
|
+
|
|
129
|
+
value
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def info_command
|
|
133
|
+
@args.shift
|
|
134
|
+
return fail_usage("info expects one input file") unless @args.one?
|
|
135
|
+
|
|
136
|
+
path = @args.first
|
|
137
|
+
announce(JSON.pretty_generate({ path:, **Operations.info(ImageIO.read(path)) }))
|
|
138
|
+
0
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
@err.puts("retouch: #{e.message}")
|
|
141
|
+
1
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def fail_usage(message)
|
|
145
|
+
@err.puts("retouch: #{message}")
|
|
146
|
+
@err.puts("Run 'retouch --help' for usage.")
|
|
147
|
+
2
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def announce(message)
|
|
151
|
+
@out.puts(message) unless @options[:quiet]
|
|
152
|
+
0
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def help(operation = nil)
|
|
156
|
+
if operation
|
|
157
|
+
raise ArgumentError, "unknown operation help: #{operation}" unless OPERATIONS.include?(operation)
|
|
158
|
+
|
|
159
|
+
@out.puts("retouch INPUT #{operation} [ARGUMENTS] -o OUTPUT")
|
|
160
|
+
return 0
|
|
161
|
+
end
|
|
162
|
+
@out.puts <<~HELP
|
|
163
|
+
Retouch resizes and reshapes PNG, PPM, and BMP images.
|
|
164
|
+
|
|
165
|
+
Usage: retouch INPUT OPERATION [ARGUMENTS] -o OUTPUT
|
|
166
|
+
retouch help OPERATION
|
|
167
|
+
retouch info INPUT
|
|
168
|
+
|
|
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
|
|
172
|
+
HELP
|
|
173
|
+
0
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Retouch
|
|
4
|
+
class Geometry
|
|
5
|
+
attr_reader :width, :height, :offset_x, :offset_y, :mode
|
|
6
|
+
|
|
7
|
+
def self.parse(value)
|
|
8
|
+
text = String(value)
|
|
9
|
+
match = /\A(?:(?<width>\d+(?:\.\d+)?%?)?x(?<height>\d+(?:\.\d+)?%?)?|(?<single>\d+(?:\.\d+)?%))(?<mode>[!^>])?(?<x>[+-]\d+)?(?<y>[+-]\d+)?\z/.match(text)
|
|
10
|
+
raise ArgumentError, "invalid geometry: #{text}" unless match
|
|
11
|
+
|
|
12
|
+
width = match[:width] || match[:single]
|
|
13
|
+
height = match[:height] || match[:single]
|
|
14
|
+
raise ArgumentError, "geometry needs at least one dimension" unless width || height
|
|
15
|
+
|
|
16
|
+
mode = { "!" => :stretch, "^" => :cover, ">" => :shrink }.fetch(match[:mode], :fit)
|
|
17
|
+
raise ArgumentError, "geometry needs both offsets" if match[:x].nil? != match[:y].nil?
|
|
18
|
+
|
|
19
|
+
new(width && dimension(width), height && dimension(height), (match[:x] || 0).to_i, (match[:y] || 0).to_i, mode)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.dimension(value)
|
|
23
|
+
number = Float(value.delete_suffix("%"), exception: false)
|
|
24
|
+
raise ArgumentError, "geometry dimensions must be positive" unless number&.positive?
|
|
25
|
+
|
|
26
|
+
value.end_with?("%") ? number / 100.0 : number.round
|
|
27
|
+
end
|
|
28
|
+
private_class_method :dimension
|
|
29
|
+
|
|
30
|
+
def initialize(width, height, offset_x, offset_y, mode)
|
|
31
|
+
@width = width
|
|
32
|
+
@height = height
|
|
33
|
+
@offset_x = offset_x
|
|
34
|
+
@offset_y = offset_y
|
|
35
|
+
@mode = mode
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def crop_size(source_width, source_height)
|
|
39
|
+
raise ArgumentError, "crop geometry needs both dimensions" unless width && height
|
|
40
|
+
|
|
41
|
+
[scaled_dimension(source_width, width), scaled_dimension(source_height, height)]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def target_size(source_width, source_height)
|
|
45
|
+
width_ratio = width.is_a?(Float) ? width : nil
|
|
46
|
+
height_ratio = height.is_a?(Float) ? height : nil
|
|
47
|
+
target_width = width_ratio ? source_width * width_ratio : width
|
|
48
|
+
target_height = height_ratio ? source_height * height_ratio : height
|
|
49
|
+
return [scaled_dimension(source_width, width_ratio), scaled_dimension(source_height, height_ratio)] if width_ratio && height_ratio
|
|
50
|
+
return [scaled_dimension(source_width, target_width), scaled_dimension(source_height, target_height)] if mode == :stretch && target_width && target_height
|
|
51
|
+
|
|
52
|
+
scale = if target_width && target_height
|
|
53
|
+
horizontal = target_width.to_f / source_width
|
|
54
|
+
vertical = target_height.to_f / source_height
|
|
55
|
+
mode == :cover ? [horizontal, vertical].max : [horizontal, vertical].min
|
|
56
|
+
elsif target_width
|
|
57
|
+
target_width.to_f / source_width
|
|
58
|
+
elsif target_height
|
|
59
|
+
target_height.to_f / source_height
|
|
60
|
+
else
|
|
61
|
+
1.0
|
|
62
|
+
end
|
|
63
|
+
scale = [scale, 1.0].min if mode == :shrink
|
|
64
|
+
[(source_width * scale).round.clamp(1, 1_000_000), (source_height * scale).round.clamp(1, 1_000_000)]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def scaled_dimension(source, value)
|
|
70
|
+
(value.is_a?(Float) ? source * value : value).round.clamp(1, 1_000_000)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Retouch
|
|
4
|
+
module Gravity
|
|
5
|
+
DIRECTIONS = {
|
|
6
|
+
north_west: [0, 0], north: [0.5, 0], north_east: [1, 0],
|
|
7
|
+
west: [0, 0.5], center: [0.5, 0.5], east: [1, 0.5],
|
|
8
|
+
south_west: [0, 1], south: [0.5, 1], south_east: [1, 1]
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def offset(container_width, container_height, content_width, content_height, gravity = :center)
|
|
14
|
+
horizontal, vertical = DIRECTIONS.fetch(gravity.to_s.tr("-", "_").to_sym) { raise ArgumentError, "unknown gravity: #{gravity}" }
|
|
15
|
+
[(container_width - content_width) * horizontal, (container_height - content_height) * vertical].map(&:round)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/retouch/io.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Retouch
|
|
4
|
+
module ImageIO
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def read(path)
|
|
8
|
+
signature = File.binread(path, 8)
|
|
9
|
+
format = if signature.start_with?(Tessel::SIGNATURE)
|
|
10
|
+
"PNG"
|
|
11
|
+
elsif signature.start_with?("P3", "P6")
|
|
12
|
+
"PPM"
|
|
13
|
+
elsif signature.start_with?("BM")
|
|
14
|
+
"BMP"
|
|
15
|
+
end
|
|
16
|
+
with_format(Tessel.read(path), format || "unknown")
|
|
17
|
+
rescue Tessel::UnsupportedError => e
|
|
18
|
+
raise Error, "unsupported image format for #{path}: #{e.message}", cause: e
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def write(image, path, level: 6, strip: false)
|
|
22
|
+
raise TypeError, "image must be a Tessel::Image" unless image.is_a?(Tessel::Image)
|
|
23
|
+
|
|
24
|
+
image = Tessel::Image.from_rgba(image.width, image.height, image.bytes) if strip
|
|
25
|
+
extension = File.extname(path).downcase
|
|
26
|
+
case extension
|
|
27
|
+
when ".png"
|
|
28
|
+
image.write(path, level: Integer(level))
|
|
29
|
+
when ".ppm", ".pnm"
|
|
30
|
+
image.write(path, binary: true)
|
|
31
|
+
when ".bmp"
|
|
32
|
+
image.write(path)
|
|
33
|
+
else
|
|
34
|
+
raise Error, "unsupported output format: #{extension.empty? ? "(no extension)" : extension}"
|
|
35
|
+
end
|
|
36
|
+
path
|
|
37
|
+
rescue ArgumentError, Tessel::Error => e
|
|
38
|
+
raise Error, e.message, cause: e
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def with_format(image, format)
|
|
42
|
+
metadata = image.metadata.merge("format" => format)
|
|
43
|
+
Tessel::Image.from_rgba(image.width, image.height, image.bytes, metadata:)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Retouch
|
|
4
|
+
module Operations
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def apply(image, name, *arguments, **options)
|
|
8
|
+
raise TypeError, "image must be a Tessel::Image" unless image.is_a?(Tessel::Image)
|
|
9
|
+
|
|
10
|
+
method = name.to_sym
|
|
11
|
+
raise ArgumentError, "unknown operation: #{name}" unless respond_to?(method)
|
|
12
|
+
|
|
13
|
+
public_send(method, image, *arguments, **options)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def info(image)
|
|
17
|
+
colors = {}
|
|
18
|
+
alpha = false
|
|
19
|
+
image.bytes.bytes.each_slice(4) do |r, g, b, a|
|
|
20
|
+
colors[[r, g, b, a]] = true
|
|
21
|
+
alpha ||= a < 255
|
|
22
|
+
end
|
|
23
|
+
{ width: image.width, height: image.height, format: image.metadata.fetch("format", "RGBA"), colors: colors.length,
|
|
24
|
+
alpha: alpha, metadata: image.metadata.dup }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def resize(image, geometry, filter: :lanczos3, gravity: :center)
|
|
28
|
+
geometry = Geometry.parse(geometry) unless geometry.is_a?(Geometry)
|
|
29
|
+
width, height = geometry.target_size(image.width, image.height)
|
|
30
|
+
output = resample(image, width, height, filter)
|
|
31
|
+
if geometry.mode == :cover && geometry.width && geometry.height
|
|
32
|
+
crop_width, crop_height = geometry.crop_size(image.width, image.height)
|
|
33
|
+
x, y = Gravity.offset(output.width, output.height, crop_width, crop_height, gravity)
|
|
34
|
+
x = geometry.offset_x unless geometry.offset_x.zero?
|
|
35
|
+
y = geometry.offset_y unless geometry.offset_y.zero?
|
|
36
|
+
output = output.crop(x.clamp(0, [output.width - crop_width, 0].max), y.clamp(0, [output.height - crop_height, 0].max), crop_width, crop_height)
|
|
37
|
+
end
|
|
38
|
+
output
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def thumbnail(image, geometry, **options)
|
|
42
|
+
result = resize(image, geometry, **options)
|
|
43
|
+
Tessel::Image.from_rgba(result.width, result.height, result.bytes)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def crop(image, geometry, gravity: :north_west)
|
|
47
|
+
geometry = Geometry.parse(geometry) unless geometry.is_a?(Geometry)
|
|
48
|
+
width, height = geometry.crop_size(image.width, image.height)
|
|
49
|
+
x, y = if geometry.offset_x.zero? && geometry.offset_y.zero?
|
|
50
|
+
Gravity.offset(image.width, image.height, width, height, gravity)
|
|
51
|
+
else
|
|
52
|
+
[geometry.offset_x, geometry.offset_y]
|
|
53
|
+
end
|
|
54
|
+
copy_metadata(image.crop(x, y, width, height), image)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def flip(image) = image.flip_vertical
|
|
58
|
+
|
|
59
|
+
def flop(image)
|
|
60
|
+
output = Tessel::Image.new(image.width, image.height, metadata: image.metadata)
|
|
61
|
+
image.height.times do |y|
|
|
62
|
+
image.width.times { |x| output[image.width - x - 1, y] = image[x, y] }
|
|
63
|
+
end
|
|
64
|
+
output
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def rotate(image, degrees, background: [0, 0, 0, 0])
|
|
68
|
+
angle = Float(degrees) % 360
|
|
69
|
+
raise ArgumentError, "degrees must be finite" unless angle.finite?
|
|
70
|
+
|
|
71
|
+
return image.dup if angle.zero?
|
|
72
|
+
return rotate_quarter(image, 1) if angle == 90
|
|
73
|
+
return rotate_quarter(image, 2) if angle == 180
|
|
74
|
+
return rotate_quarter(image, 3) if angle == 270
|
|
75
|
+
|
|
76
|
+
radians = angle * Math::PI / 180
|
|
77
|
+
cosine = Math.cos(radians)
|
|
78
|
+
sine = Math.sin(radians)
|
|
79
|
+
width = ((image.width * cosine.abs) + (image.height * sine.abs)).ceil
|
|
80
|
+
height = ((image.width * sine.abs) + (image.height * cosine.abs)).ceil
|
|
81
|
+
output = Tessel::Image.new(width, height, fill: background, metadata: image.metadata)
|
|
82
|
+
source_cx = (image.width - 1) / 2.0
|
|
83
|
+
source_cy = (image.height - 1) / 2.0
|
|
84
|
+
target_cx = (width - 1) / 2.0
|
|
85
|
+
target_cy = (height - 1) / 2.0
|
|
86
|
+
height.times do |y|
|
|
87
|
+
width.times do |x|
|
|
88
|
+
dx = x - target_cx
|
|
89
|
+
dy = y - target_cy
|
|
90
|
+
sx = (cosine * dx) + (sine * dy) + source_cx
|
|
91
|
+
sy = (-sine * dx) + (cosine * dy) + source_cy
|
|
92
|
+
output[x, y] = sample_bilinear(image, sx, sy, background) if sx.between?(0, image.width - 1) && sy.between?(0, image.height - 1)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
output
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def pad(image, amount, color: [0, 0, 0, 0])
|
|
99
|
+
amount = Integer(amount)
|
|
100
|
+
raise ArgumentError, "padding must not be negative" if amount.negative?
|
|
101
|
+
|
|
102
|
+
extend(image, image.width + (2 * amount), image.height + (2 * amount), color:, gravity: :center)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def extend(image, width, height, color: [0, 0, 0, 0], gravity: :center)
|
|
106
|
+
width = Integer(width)
|
|
107
|
+
height = Integer(height)
|
|
108
|
+
raise ArgumentError, "canvas dimensions must be positive" unless width.positive? && height.positive?
|
|
109
|
+
raise ArgumentError, "canvas cannot be smaller than image" if width < image.width || height < image.height
|
|
110
|
+
|
|
111
|
+
x, y = Gravity.offset(width, height, image.width, image.height, gravity)
|
|
112
|
+
Tessel::Image.new(width, height, fill: color, metadata: image.metadata).tap { |canvas| canvas.blit(image, x, y) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def border(image, width, color = "#000000")
|
|
116
|
+
width = Integer(width)
|
|
117
|
+
raise ArgumentError, "border width must be positive" unless width.positive?
|
|
118
|
+
|
|
119
|
+
pad(image, width, color:)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def trim(image, color: nil, fuzz: 0)
|
|
123
|
+
fuzz = Integer(fuzz)
|
|
124
|
+
raise ArgumentError, "fuzz must be between 0 and 255" unless fuzz.between?(0, 255)
|
|
125
|
+
|
|
126
|
+
target = Tessel::Color.pack(color || image[0, 0] || [0, 0, 0, 0]).bytes
|
|
127
|
+
bounds = [image.width, image.height, -1, -1]
|
|
128
|
+
image.height.times do |y|
|
|
129
|
+
image.width.times do |x|
|
|
130
|
+
pixel = image[x, y]
|
|
131
|
+
next if pixel.zip(target).all? { |a, b| (a - b).abs <= fuzz }
|
|
132
|
+
|
|
133
|
+
bounds[0] = [bounds[0], x].min
|
|
134
|
+
bounds[1] = [bounds[1], y].min
|
|
135
|
+
bounds[2] = [bounds[2], x].max
|
|
136
|
+
bounds[3] = [bounds[3], y].max
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
return image.dup if bounds[2].negative?
|
|
140
|
+
|
|
141
|
+
copy_metadata(image.crop(bounds[0], bounds[1], bounds[2] - bounds[0] + 1, bounds[3] - bounds[1] + 1), image)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def resample(image, width, height, filter)
|
|
145
|
+
filter = filter.to_sym
|
|
146
|
+
raise ArgumentError, "unknown resize filter: #{filter}" unless %i[nearest bilinear bicubic lanczos3].include?(filter)
|
|
147
|
+
return image.scale_nearest(width, height) if filter == :nearest
|
|
148
|
+
|
|
149
|
+
horizontal = axis_weights(image.width, width, filter)
|
|
150
|
+
vertical = axis_weights(image.height, height, filter)
|
|
151
|
+
source = image.bytes
|
|
152
|
+
intermediate = String.new(capacity: width * image.height * 4, encoding: Encoding::BINARY)
|
|
153
|
+
image.height.times do |y|
|
|
154
|
+
width.times do |x|
|
|
155
|
+
sums = [0.0, 0.0, 0.0, 0.0]
|
|
156
|
+
horizontal[x].each do |sx, weight|
|
|
157
|
+
offset = ((y * image.width) + sx) * 4
|
|
158
|
+
alpha = source.getbyte(offset + 3) / 255.0
|
|
159
|
+
sums[0] += source.getbyte(offset) * alpha * weight
|
|
160
|
+
sums[1] += source.getbyte(offset + 1) * alpha * weight
|
|
161
|
+
sums[2] += source.getbyte(offset + 2) * alpha * weight
|
|
162
|
+
sums[3] += source.getbyte(offset + 3) * weight
|
|
163
|
+
end
|
|
164
|
+
sums.each { |value| intermediate << value.round.clamp(0, 255) }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
result = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
|
|
168
|
+
height.times do |y|
|
|
169
|
+
width.times do |x|
|
|
170
|
+
sums = [0.0, 0.0, 0.0, 0.0]
|
|
171
|
+
vertical[y].each do |sy, weight|
|
|
172
|
+
offset = ((sy * width) + x) * 4
|
|
173
|
+
4.times { |channel| sums[channel] += intermediate.getbyte(offset + channel) * weight }
|
|
174
|
+
end
|
|
175
|
+
alpha = sums[3].round.clamp(0, 255)
|
|
176
|
+
if alpha.positive?
|
|
177
|
+
3.times { |channel| result << (sums[channel] * 255 / alpha).round.clamp(0, 255) }
|
|
178
|
+
else
|
|
179
|
+
result << "\0\0\0".b
|
|
180
|
+
end
|
|
181
|
+
result << alpha
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
Tessel::Image.from_rgba(width, height, result, metadata: image.metadata)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def axis_weights(source, target, filter)
|
|
188
|
+
radius = { bilinear: 1, bicubic: 2, lanczos3: 3 }.fetch(filter)
|
|
189
|
+
scale = [source.to_f / target, 1.0].max
|
|
190
|
+
(0...target).map do |out|
|
|
191
|
+
center = ((out + 0.5) * source / target) - 0.5
|
|
192
|
+
first = (center - (radius * scale)).floor
|
|
193
|
+
last = (center + (radius * scale)).ceil
|
|
194
|
+
weights = Hash.new(0.0)
|
|
195
|
+
(first..last).each do |index|
|
|
196
|
+
distance = (center - index) / scale
|
|
197
|
+
weight = case filter
|
|
198
|
+
when :bilinear then [1 - distance.abs, 0].max
|
|
199
|
+
when :bicubic then cubic(distance)
|
|
200
|
+
when :lanczos3 then lanczos(distance)
|
|
201
|
+
end
|
|
202
|
+
weights[index.clamp(0, source - 1)] += weight
|
|
203
|
+
end
|
|
204
|
+
total = weights.values.sum
|
|
205
|
+
weights.map { |index, weight| [index, weight / total] }
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def cubic(value)
|
|
210
|
+
value = value.abs
|
|
211
|
+
return (((1.5 * value) - 2.5) * value * value) + 1 if value < 1
|
|
212
|
+
return (((((-0.5 * value) + 2.5) * value) - 4) * value) + 2 if value < 2
|
|
213
|
+
|
|
214
|
+
0.0
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def lanczos(value)
|
|
218
|
+
value = value.abs
|
|
219
|
+
return 1.0 if value.zero?
|
|
220
|
+
return 0.0 if value >= 3
|
|
221
|
+
|
|
222
|
+
Math.sin(Math::PI * value) * Math.sin(Math::PI * value / 3) * 3 / (Math::PI * Math::PI * value * value)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def copy_metadata(output, source)
|
|
226
|
+
Tessel::Image.from_rgba(output.width, output.height, output.bytes, metadata: source.metadata)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def rotate_quarter(image, turns)
|
|
230
|
+
width, height = turns.odd? ? [image.height, image.width] : [image.width, image.height]
|
|
231
|
+
output = Tessel::Image.new(width, height, metadata: image.metadata)
|
|
232
|
+
image.height.times do |y|
|
|
233
|
+
image.width.times do |x|
|
|
234
|
+
dx, dy = case turns
|
|
235
|
+
when 1 then [image.height - y - 1, x]
|
|
236
|
+
when 2 then [image.width - x - 1, image.height - y - 1]
|
|
237
|
+
else [y, image.width - x - 1]
|
|
238
|
+
end
|
|
239
|
+
output[dx, dy] = image[x, y]
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
copy_metadata(output, image)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def sample_bilinear(image, x, y, background)
|
|
246
|
+
x0 = x.floor
|
|
247
|
+
y0 = y.floor
|
|
248
|
+
fx = x - x0
|
|
249
|
+
fy = y - y0
|
|
250
|
+
pixels = [[x0, y0, (1 - fx) * (1 - fy)], [x0 + 1, y0, fx * (1 - fy)], [x0, y0 + 1, (1 - fx) * fy], [x0 + 1, y0 + 1, fx * fy]]
|
|
251
|
+
sums = [0.0, 0.0, 0.0, 0.0]
|
|
252
|
+
pixels.each do |px, py, weight|
|
|
253
|
+
color = image[px.clamp(0, image.width - 1), py.clamp(0, image.height - 1)] || Tessel::Color.pack(background).bytes
|
|
254
|
+
alpha = color[3] / 255.0
|
|
255
|
+
3.times { |channel| sums[channel] += color[channel] * alpha * weight }
|
|
256
|
+
sums[3] += color[3] * weight
|
|
257
|
+
end
|
|
258
|
+
alpha = sums[3].round.clamp(0, 255)
|
|
259
|
+
[*(alpha.positive? ? sums.first(3).map { |value| (value * 255 / alpha).round.clamp(0, 255) } : [0, 0, 0]), alpha]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
private_class_method :resample, :axis_weights, :cubic, :lanczos, :copy_metadata, :rotate_quarter, :sample_bilinear
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Retouch
|
|
4
|
+
class Pipeline
|
|
5
|
+
OPERATIONS = %i[resize thumbnail crop flip flop rotate pad extend border trim].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(image, operations = [])
|
|
8
|
+
raise TypeError, "image must be a Tessel::Image" unless image.is_a?(Tessel::Image)
|
|
9
|
+
|
|
10
|
+
@source = image.dup
|
|
11
|
+
@operations = operations.freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_image
|
|
15
|
+
@operations.reduce(@source.dup) do |image, (name, args, options)|
|
|
16
|
+
args = args.dup
|
|
17
|
+
Operations.apply(image, name, *args, **options)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def info = Operations.info(to_image)
|
|
22
|
+
|
|
23
|
+
def save(path, **options)
|
|
24
|
+
ImageIO.write(to_image, String(path), **options)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def method_missing(name, *args, **options, &block)
|
|
28
|
+
return super if block || !OPERATIONS.include?(name)
|
|
29
|
+
|
|
30
|
+
self.class.new(@source, @operations + [[name, args, options]])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def respond_to_missing?(name, include_private = false)
|
|
34
|
+
OPERATIONS.include?(name) || super
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/retouch.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tessel"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "retouch/version"
|
|
6
|
+
require_relative "retouch/geometry"
|
|
7
|
+
require_relative "retouch/gravity"
|
|
8
|
+
require_relative "retouch/operations"
|
|
9
|
+
require_relative "retouch/pipeline"
|
|
10
|
+
require_relative "retouch/cli"
|
|
11
|
+
require_relative "retouch/io"
|
|
12
|
+
|
|
13
|
+
module Retouch
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
def self.open(path)
|
|
17
|
+
Pipeline.new(ImageIO.read(path))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.from_image(image)
|
|
21
|
+
Pipeline.new(image)
|
|
22
|
+
end
|
|
23
|
+
end
|
data/retouch.gemspec
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/retouch/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "retouch"
|
|
7
|
+
spec.version = Retouch::VERSION
|
|
8
|
+
spec.authors = ["Yudai Takada"]
|
|
9
|
+
spec.email = ["t.yudai92@gmail.com"]
|
|
10
|
+
spec.summary = "Ruby image resizing and shape tools"
|
|
11
|
+
spec.description = "A small Ruby image editor and command line tool for resizing and shape operations."
|
|
12
|
+
spec.homepage = "https://github.com/rbgfx/retouch"
|
|
13
|
+
spec.license = "MIT"
|
|
14
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
15
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
16
|
+
spec.metadata["source_code_uri"] = "#{spec.homepage}/tree/main"
|
|
17
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
18
|
+
|
|
19
|
+
spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
|
|
20
|
+
ls.readlines("\x0", chomp: true).reject do |file|
|
|
21
|
+
file.start_with?(*%w[bin/ Gemfile .gitignore .rspec spec/ test/ .github/]) || file == "Gemfile.lock"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
spec.bindir = "exe"
|
|
25
|
+
spec.executables = ["retouch"]
|
|
26
|
+
spec.require_paths = ["lib"]
|
|
27
|
+
|
|
28
|
+
spec.add_dependency "tessel", ">= 0.2.0", "< 1"
|
|
29
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
30
|
+
spec.add_development_dependency "rbs", "~> 3.0"
|
|
31
|
+
spec.add_development_dependency "rubocop", "~> 1.0"
|
|
32
|
+
spec.add_development_dependency "simplecov", "~> 0.22"
|
|
33
|
+
spec.add_development_dependency "test-unit", "~> 3.6"
|
|
34
|
+
end
|
data/sig/retouch.rbs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Retouch
|
|
2
|
+
VERSION: String
|
|
3
|
+
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class Geometry
|
|
8
|
+
attr_reader width: Integer | Float | nil
|
|
9
|
+
attr_reader height: Integer | Float | nil
|
|
10
|
+
attr_reader offset_x: Integer
|
|
11
|
+
attr_reader offset_y: Integer
|
|
12
|
+
attr_reader mode: Symbol
|
|
13
|
+
|
|
14
|
+
def self.parse: (String value) -> Geometry
|
|
15
|
+
def crop_size: (Integer source_width, Integer source_height) -> [Integer, Integer]
|
|
16
|
+
def target_size: (Integer source_width, Integer source_height) -> [Integer, Integer]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module Gravity
|
|
20
|
+
def self.offset: (Integer container_width, Integer container_height, Integer content_width, Integer content_height, Symbol | String gravity) -> [Integer, Integer]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class Pipeline
|
|
24
|
+
def to_image: -> untyped
|
|
25
|
+
def info: -> Hash[Symbol, untyped]
|
|
26
|
+
def save: (String path, ?level: Integer, ?strip: bool) -> String
|
|
27
|
+
def resize: (String | Geometry geometry, **untyped options) -> Pipeline
|
|
28
|
+
def thumbnail: (String | Geometry geometry, **untyped options) -> Pipeline
|
|
29
|
+
def crop: (String | Geometry geometry, **untyped options) -> Pipeline
|
|
30
|
+
def flip: -> Pipeline
|
|
31
|
+
def flop: -> Pipeline
|
|
32
|
+
def rotate: (Numeric degrees, **untyped options) -> Pipeline
|
|
33
|
+
def pad: (Integer amount, **untyped options) -> Pipeline
|
|
34
|
+
def extend: (Integer width, Integer height, **untyped options) -> Pipeline
|
|
35
|
+
def border: (Integer width, ?String color, **untyped options) -> Pipeline
|
|
36
|
+
def trim: (**untyped options) -> Pipeline
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.open: (String path) -> Pipeline
|
|
40
|
+
def self.from_image: (untyped image) -> Pipeline
|
|
41
|
+
|
|
42
|
+
class CLI
|
|
43
|
+
def self.run: (Array[String] argv, ?out: untyped, ?err: untyped) -> Integer
|
|
44
|
+
end
|
|
45
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: retouch
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: tessel
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.2.0
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '1'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 0.2.0
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '1'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: rake
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '13.0'
|
|
39
|
+
type: :development
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '13.0'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: rbs
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - "~>"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '3.0'
|
|
53
|
+
type: :development
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '3.0'
|
|
60
|
+
- !ruby/object:Gem::Dependency
|
|
61
|
+
name: rubocop
|
|
62
|
+
requirement: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - "~>"
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '1.0'
|
|
67
|
+
type: :development
|
|
68
|
+
prerelease: false
|
|
69
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - "~>"
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '1.0'
|
|
74
|
+
- !ruby/object:Gem::Dependency
|
|
75
|
+
name: simplecov
|
|
76
|
+
requirement: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - "~>"
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0.22'
|
|
81
|
+
type: :development
|
|
82
|
+
prerelease: false
|
|
83
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - "~>"
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '0.22'
|
|
88
|
+
- !ruby/object:Gem::Dependency
|
|
89
|
+
name: test-unit
|
|
90
|
+
requirement: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - "~>"
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '3.6'
|
|
95
|
+
type: :development
|
|
96
|
+
prerelease: false
|
|
97
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - "~>"
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '3.6'
|
|
102
|
+
description: A small Ruby image editor and command line tool for resizing and shape
|
|
103
|
+
operations.
|
|
104
|
+
email:
|
|
105
|
+
- t.yudai92@gmail.com
|
|
106
|
+
executables:
|
|
107
|
+
- retouch
|
|
108
|
+
extensions: []
|
|
109
|
+
extra_rdoc_files: []
|
|
110
|
+
files:
|
|
111
|
+
- ".rubocop.yml"
|
|
112
|
+
- CHANGELOG.md
|
|
113
|
+
- LICENSE
|
|
114
|
+
- README.md
|
|
115
|
+
- Rakefile
|
|
116
|
+
- bench/retouch.rb
|
|
117
|
+
- exe/retouch
|
|
118
|
+
- lib/retouch.rb
|
|
119
|
+
- lib/retouch/cli.rb
|
|
120
|
+
- lib/retouch/geometry.rb
|
|
121
|
+
- lib/retouch/gravity.rb
|
|
122
|
+
- lib/retouch/io.rb
|
|
123
|
+
- lib/retouch/operations.rb
|
|
124
|
+
- lib/retouch/pipeline.rb
|
|
125
|
+
- lib/retouch/version.rb
|
|
126
|
+
- retouch.gemspec
|
|
127
|
+
- sig/retouch.rbs
|
|
128
|
+
homepage: https://github.com/rbgfx/retouch
|
|
129
|
+
licenses:
|
|
130
|
+
- MIT
|
|
131
|
+
metadata:
|
|
132
|
+
homepage_uri: https://github.com/rbgfx/retouch
|
|
133
|
+
source_code_uri: https://github.com/rbgfx/retouch/tree/main
|
|
134
|
+
rubygems_mfa_required: 'true'
|
|
135
|
+
rdoc_options: []
|
|
136
|
+
require_paths:
|
|
137
|
+
- lib
|
|
138
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
139
|
+
requirements:
|
|
140
|
+
- - ">="
|
|
141
|
+
- !ruby/object:Gem::Version
|
|
142
|
+
version: 3.1.0
|
|
143
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
144
|
+
requirements:
|
|
145
|
+
- - ">="
|
|
146
|
+
- !ruby/object:Gem::Version
|
|
147
|
+
version: '0'
|
|
148
|
+
requirements: []
|
|
149
|
+
rubygems_version: 4.0.16
|
|
150
|
+
specification_version: 4
|
|
151
|
+
summary: Ruby image resizing and shape tools
|
|
152
|
+
test_files: []
|