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 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
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ All notable changes to Retouch are recorded here.
4
+
5
+ ## [0.1.0] - 2026-09-25
6
+
7
+ Initial release
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&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>
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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "retouch"
5
+
6
+ exit Retouch::CLI.run(ARGV)
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Retouch
4
+ VERSION = "0.1.0"
5
+ 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: []