retouch 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79c42e7a96d4184640a68bfd3205bf2d89c99bae8849f5c7e77fafd258a97738
4
- data.tar.gz: 99fd548525bcf5374f83a559746061a4e6eb33aca77679f15e21a6b3ae4d913e
3
+ metadata.gz: ded00b784bc38feace65825e78c1a054c85e25deaa24c3646f0bb42413cbb678
4
+ data.tar.gz: 25e71fe884b758478af207538e4652d9c3d9186a10d7333045d8f9c7f85e9fe8
5
5
  SHA512:
6
- metadata.gz: 6187dc735d131af9ad3e6dc57f4cdb4fac3eb3f6787804a0498da4fbb21aafb8bb571b5edbc98eb4dec242c8c66d805eedcec03f4326d50e0b39f72f68638154
7
- data.tar.gz: b11da4111c899723b42cadf4cc87196bf86f4cb83d39c7ec506f9d08010a2b96ff8df6db8da04b4a5fae7885c702c2c6c77c3d0138b4d48f5d893fd700d06a69
6
+ metadata.gz: fef149f89fed473a1f2e648bb0162e7957bc28a799850026743ec872777ba7bc87b1e82ae3db15b43beb927a39fa93a2dd40af297c37495f1de245591ff140d2
7
+ data.tar.gz: d3d5e48248efadcd180e51085a4d92cd8b4b560d21ea1a968ca4c95fe07953794db2cdd29db007109170548d680e10e1ec495fe17a061583956a259b23ee321e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to Retouch are recorded here.
4
4
 
5
+ ## [0.3.1] - 2026-09-25
6
+
7
+ - Use normalized fixed-point weights for resampling with integer interpolation.
8
+ - Require Flipbook 0.4.0 for GIF and APNG integration.
9
+
10
+ ## [0.3.0] - 2026-09-25
11
+
12
+ - Add color, quantization, convolution, pixelation, compositing, annotation, and multi-image operations.
13
+ - Add glob-based batch transforms, output templates, and process workers.
14
+ - Add optional Glyphic text and Flipbook 0.3.0+ GIF reading, GIF output, and APNG output integrations.
15
+ - Document current format and GIF Reader limitations.
16
+
5
17
  ## [0.1.0] - 2026-09-25
6
18
 
7
19
  Initial release
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <h1 align="center">Retouch</h1>
2
2
 
3
- <p align="center">Resize and reshape images with a small Ruby API and CLI.</p>
3
+ <p align="center">A small Ruby image editor for PNG, PPM, and BMP.</p>
4
4
 
5
5
  <p align="center">
6
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>
@@ -8,41 +8,95 @@
8
8
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-750014.svg" alt="MIT license"></a>
9
9
  </p>
10
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.
11
+ Retouch provides a small image-processing API and CLI backed by [Tessel](https://github.com/rbgfx/tessel). It runs in pure Ruby and is intended for screenshots, pixel art, and CI image tasks. JPEG, WebP, and color management are outside its current scope.
12
12
 
13
13
  ## Install
14
14
 
15
- Until the first RubyGems release, add the repository to your Gemfile:
15
+ ```sh
16
+ gem install retouch
17
+ ```
16
18
 
17
- ```ruby
18
- gem "retouch", github: "rbgfx/retouch"
19
+ Install optional integrations only when needed:
20
+
21
+ ```sh
22
+ gem install glyphic # text and montage labels
23
+ gem install flipbook -v '>= 0.4.0' # GIF reading, GIF output, and APNG output
19
24
  ```
20
25
 
21
26
  ## Ruby API
22
27
 
28
+ Pipelines are lazy: each operation returns a new pipeline, and rendering starts at `to_image` or `save`.
29
+
23
30
  ```ruby
24
31
  require "retouch"
25
32
 
26
33
  Retouch.open("screenshot.png")
27
34
  .resize("640x")
35
+ .brightness(8)
28
36
  .border(1, "#303846")
29
37
  .save("small.png")
30
38
  ```
31
39
 
32
- Pipelines defer work until `to_image` or `save` and leave the source image unchanged.
40
+ Available operations include `resize`, `thumbnail`, `crop`, `trim`, `pad`, `extend`, `border`, `rotate`, `flip`, `flop`, `grayscale`, `invert`, `brightness`, `contrast`, `gamma`, `saturate`, `tint`, `opacity`, `quantize`, `blur`, `sharpen`, `pixelate`, `overlay`, `watermark`, `text`, `rect`, and `arrow`.
41
+
42
+ ```ruby
43
+ image = Retouch.open("screen.png")
44
+ .resize("800x")
45
+ .overlay("logo.png", gravity: :south_east, opacity: 0.8, mode: :multiply)
46
+ .text("Build passed", at: :north_west, size: 18, background: "#18202ddd")
47
+ image.save("annotated.png")
48
+ ```
49
+
50
+ `brightness` adds a channel value from -255 to 255. `contrast` and `saturate` use 1 as unchanged; `gamma` also uses 1 as unchanged. `tint` takes `amount: 0..1`, and `opacity` takes a multiplier from 0 to 1. `quantize` accepts 2–256 colors and Tessel's `:none`, `:ordered`, or `:floyd_steinberg` dithering. `blur` and `sharpen` take a Gaussian sigma. `pixelate(size)` averages each size-by-size block.
51
+
52
+ GIF frames can be read as composited Tessel images:
53
+
54
+ ```ruby
55
+ frames = Retouch.open_gif("animation.gif")
56
+ first_frame = Retouch.open_gif("animation.gif", frame: :first).to_image
57
+ ```
58
+
59
+ Drawing and composite methods accept hex colors, including alpha (`#rrggbbaa`). Blend modes are `normal`, `multiply`, `screen`, `overlay`, `darken`, `lighten`, and `add`. `text` and `montage` labels need Glyphic; without it, those calls explain how to install the optional gem.
33
60
 
34
61
  ## CLI
35
62
 
36
63
  ```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
64
+ retouch screen.png resize 640x --filter lanczos3 -o small.png
65
+ retouch screen.png grayscale contrast 1.1 -o adjusted.png
66
+ retouch screen.png crop 320x200+40+20 text "v1.2" --at south-east --size 18 -o crop.png
67
+ retouch base.png overlay logo.png --gravity south-east --opacity 0.8 -o marked.png
68
+ retouch a.png b.png c.png montage --cols 3 --gap 8 -o sheet.png
69
+ retouch a.png b.png append --direction horizontal -o row.png
70
+ retouch 'frames/*.png' animate --fps 24 -o preview.gif
71
+ retouch 'frames/*.png' animate --delay 0.08 -o preview.apng
72
+ retouch expected.png actual.png diff -o diff.png
73
+ retouch info screen.png
74
+ ```
75
+
76
+ Quote a glob to let Retouch expand it. One-to-one processing accepts output templates and `--jobs`:
77
+
78
+ ```sh
79
+ retouch 'screens/*.png' thumbnail 320x -o 'thumbs/{name}-{index:03}.png' --jobs 4
41
80
  ```
42
81
 
82
+ Templates support `{name}`, `{ext}`, `{dir}`, `{index}`, and zero-padded `{index:03}`. Duplicate output paths and accidental overwrites are rejected; use `--force` to replace existing files. Without `Process.fork`, jobs run sequentially. `spritesheet` writes a JSON sidecar with each image's coordinates and packs with a simple maximum-rectangles heuristic.
83
+
43
84
  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
85
 
45
- Outputs are protected from accidental overwrite; use `--force` to replace one. `--dry-run`, `--verbose`, `--quiet`, `--strip`, and `--level 0..9` control a command.
86
+ ## How Retouch differs from ImageMagick
87
+
88
+ Retouch uses ImageMagick-style geometry as a familiar shorthand, but it does not implement the full ImageMagick command language or promise pixel-for-pixel compatibility. It is a small Ruby library and CLI built on Tessel, supports a narrower set of formats and operations, and does not provide JPEG/WebP codecs or color management. Choose ImageMagick when broad format support or mature photo-processing filters matter; choose Retouch for lightweight PNG-centered Ruby and CI workflows.
89
+
90
+ Global options include `--dry-run`, `--verbose`, `--quiet`, `--strip`, and `--level 0..9`. `retouch help OPERATION` shows that operation's syntax.
91
+
92
+ ## Formats and limits
93
+
94
+ - PNG, PPM, and BMP input and output through Tessel.
95
+ - GIF input and GIF/APNG output through optional Flipbook 0.4.0 or newer. `animate` selects GIF or APNG from the output extension; `--fps` and `--delay` are mutually exclusive, and delay is in seconds.
96
+ - Multi-image `diff` compares RGBA channels exactly by default and returns a magenta diff image plus the changed-pixel ratio in the Ruby API.
97
+ - Large photographic images and high-quality arbitrary-angle rotation can be slow in pure Ruby. JPEG, WebP, and color-managed workflows are not supported.
98
+
99
+ On Ruby 4.0.6 with YJIT, the local benchmark measured a 1920×1080 Gaussian blur at σ=3 in 5.836 seconds, above the 3-second target. Results depend on Ruby and hardware; use `--verbose` to measure your own workload.
46
100
 
47
101
  ## Development
48
102
 
data/bench/retouch.rb CHANGED
@@ -14,6 +14,9 @@ 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
16
  measure("1920x1080 -> 960x540 nearest") { Retouch::Operations.resize(image, "960x540", filter: :nearest) }
17
+ measure("1920x1080 brightness") { Retouch::Operations.brightness(image, 8) }
18
+ measure("1920x1080 contrast") { Retouch::Operations.contrast(image, 1.1) }
19
+ measure("1920x1080 blur sigma=3") { Retouch::Operations.blur(image, 3) }
17
20
  measure("256x256 -> 1024x1024 nearest") do
18
21
  Retouch::Operations.resize(Tessel::Image.new(256, 256), "400%", filter: :nearest)
19
22
  end
data/docs/index.html ADDED
@@ -0,0 +1,28 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="theme-color" content="#111315">
7
+ <meta name="description" content="A small Ruby image editor for PNG, PPM, and BMP.">
8
+ <title>Retouch — image work in Ruby</title>
9
+ <style>
10
+ :root{color-scheme:dark;--bg:#111315;--panel:#1a1d20;--line:#2c3034;--text:#f1f1ec;--muted:#a2a7a9;--accent:#79d9c5;--mono:ui-monospace,SFMono-Regular,Menlo,monospace;--sans:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
11
+ *{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 20% -20%,#1d3934,transparent 44%),var(--bg);color:var(--text);font:16px/1.65 var(--sans)}a{color:inherit;text-decoration:none}a:hover{color:var(--accent)}.wrap{width:min(1040px,calc(100% - 40px));margin:auto}.nav{height:76px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{font-size:20px;font-weight:750;letter-spacing:-.04em}.brand span,.label{color:var(--accent)}.links{display:flex;gap:24px;color:var(--muted);font-size:14px}.hero{padding:88px 0 70px;display:grid;grid-template-columns:1.1fr .9fr;align-items:center;gap:50px}.label{font:12px var(--mono);letter-spacing:.14em;text-transform:uppercase}h1{font-size:clamp(48px,7vw,78px);line-height:.99;letter-spacing:-.075em;margin:19px 0}.hero p{font-size:18px;color:var(--muted)}.actions{display:flex;gap:12px;flex-wrap:wrap;margin:28px 0 22px}.button{padding:10px 16px;border:1px solid var(--line);border-radius:5px;font-weight:650}.primary{background:var(--accent);border-color:var(--accent);color:#10211d}.install{font:14px var(--mono);background:#090b0c;border:1px solid var(--line);padding:12px 15px;border-radius:6px;display:inline-flex;gap:16px}.install span{color:#737a7b}.art{aspect-ratio:1;border:1px solid var(--line);border-radius:12px;background:linear-gradient(145deg,#202825,#141719);padding:28px;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:12px;transform:rotate(2deg)}.tile{border:1px solid #36433f;border-radius:7px;background:linear-gradient(145deg,#87dbc5,#36594f);position:relative;overflow:hidden}.tile:nth-child(2){background:linear-gradient(145deg,#e0a577,#704d39)}.tile:nth-child(3){background:linear-gradient(145deg,#6d92c7,#354764)}.tile:nth-child(4){background:linear-gradient(145deg,#d8d49c,#6e7150)}.tile:after{content:"";position:absolute;width:80%;height:80%;border:1px solid #ffffff70;inset:10%;border-radius:50%}.tile:nth-child(even):after{border-radius:2px;transform:rotate(24deg)}.section{border-top:1px solid var(--line);padding:42px 0 66px}.section h2{font-size:28px;letter-spacing:-.04em;margin:8px 0 10px}.section>p{max-width:650px;color:var(--muted)}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:25px}.card{background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:19px}.card strong{display:block;margin-bottom:7px}.card p{color:var(--muted);font-size:14px;margin:0}.code{border:1px solid var(--line);background:#090b0c;border-radius:7px;padding:18px;overflow:auto;font:14px/1.8 var(--mono);color:#d3eee5}.foot{border-top:1px solid var(--line);padding:24px 0 36px;display:flex;justify-content:space-between;gap:16px;color:var(--muted);font-size:13px}
12
+ @media(max-width:740px){.hero{grid-template-columns:1fr;gap:30px;padding:66px 0 54px}.art{max-width:400px}.links{gap:14px}.cards{grid-template-columns:1fr}.foot{flex-direction:column}}
13
+ </style>
14
+ </head>
15
+ <body><div class="wrap">
16
+ <nav class="nav" aria-label="Main navigation"><a class="brand" href="#top">re<span>touch</span></a><div class="links"><a href="#pipeline">Pipeline</a><a href="https://github.com/rbgfx/retouch">GitHub</a><a href="https://rubygems.org/gems/retouch">RubyGems</a></div></nav>
17
+ <main id="top">
18
+ <section class="hero"><div><div class="label">Image tools for Ruby</div><h1>Small edits.<br>Clean pipeline.</h1><p>Resize, crop, annotate, and compose PNG-centered images from Ruby or the command line. Retouch is pure Ruby and built on Tessel.</p><div class="actions"><a class="button primary" href="https://github.com/rbgfx/retouch#ruby-api">Read the guide ↗</a><a class="button" href="https://github.com/rbgfx/retouch#cli">Explore the CLI</a></div><div class="install"><span>$</span> gem install retouch</div></div><div class="art" aria-hidden="true"><div class="tile"></div><div class="tile"></div><div class="tile"></div><div class="tile"></div></div></section>
19
+ <section class="section" id="pipeline"><div class="label">Lazy, composable operations</div><h2>Keep image steps readable</h2><p>Build a pipeline with chainable transformations. Processing starts only when you render or save the result.</p><pre class="code"><code>require "retouch"
20
+
21
+ Retouch.open("screenshot.png")
22
+ .resize("640x")
23
+ .brightness(8)
24
+ .border(1, "#303846")
25
+ .save("small.png")</code></pre><div class="cards"><article class="card"><strong>Ruby API</strong><p>Compose resizing, color adjustments, drawing, overlays, and image comparisons.</p></article><article class="card"><strong>Practical CLI</strong><p>Process globs in batches, create montages, and automate image checks in CI.</p></article><article class="card"><strong>Focused formats</strong><p>PNG, PPM, and BMP through Tessel, with optional GIF and APNG through Flipbook.</p></article></div></section>
26
+ </main><footer class="foot"><span>Retouch · Image work in Ruby</span><span><a href="https://github.com/rbgfx/retouch">Source</a> · MIT License</span></footer>
27
+ </div></body>
28
+ </html>
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Retouch
4
+ class Batch
5
+ def self.expand(inputs)
6
+ Array(inputs).flat_map do |input|
7
+ pattern = String(input)
8
+ if pattern.match?(/[?*{\[]/)
9
+ Dir.glob(pattern)
10
+ else
11
+ pattern
12
+ end
13
+ end.sort
14
+ end
15
+
16
+ def self.output_path(template, input, index)
17
+ path = String(template).gsub("{name}", File.basename(input, File.extname(input)))
18
+ .gsub("{ext}", File.extname(input).delete_prefix("."))
19
+ .gsub("{dir}", File.dirname(input))
20
+ .gsub(/\{index(?::(\d+))?\}/) { format("%0*d", Regexp.last_match(1).to_i, index) }
21
+ raise ArgumentError, "unknown output template token in #{path}" if path.match?(/\{[^}]+\}/)
22
+
23
+ path
24
+ end
25
+
26
+ def self.run(inputs, to:, jobs: 1, force: false, dry_run: false)
27
+ paths = expand(inputs)
28
+ raise Error, "no input files matched" if paths.empty?
29
+ raise Error, "input file does not exist: #{paths.find { |path| !File.file?(path) }}" unless paths.all? { |path| File.file?(path) }
30
+
31
+ outputs = paths.each_with_index.map { |path, index| output_path(to, path, index) }
32
+ raise Error, "output template produces duplicate paths" unless outputs.uniq.length == outputs.length
33
+
34
+ jobs = Integer(jobs)
35
+ raise ArgumentError, "jobs must be positive" unless jobs.positive?
36
+ if !force && (existing = outputs.find { |path| File.exist?(path) })
37
+ raise Error, "refusing to overwrite #{existing}; use --force"
38
+ end
39
+ return outputs if dry_run
40
+
41
+ worker_count = Process.respond_to?(:fork) ? jobs : 1
42
+ children = []
43
+ failed = false
44
+ paths.zip(outputs).each do |input, output|
45
+ FileUtils.mkdir_p(File.dirname(output)) unless File.dirname(output) == "."
46
+ if worker_count > 1
47
+ children << Process.fork do
48
+ yield input, output
49
+ exit! 0
50
+ rescue StandardError => e
51
+ warn("retouch: #{e.message}")
52
+ exit! 1
53
+ end
54
+ if children.length >= worker_count
55
+ _pid, status = Process.wait2(children.shift)
56
+ failed ||= !status.success?
57
+ end
58
+ else
59
+ yield input, output
60
+ end
61
+ end
62
+ children.each do |pid|
63
+ _child, status = Process.wait2(pid)
64
+ failed ||= !status.success?
65
+ end
66
+ raise Error, "one or more batch jobs failed" if failed
67
+
68
+ outputs
69
+ end
70
+ end
71
+
72
+ def self.batch(inputs, to:, jobs: 1, force: false, &block)
73
+ raise ArgumentError, "a transformation block is required" unless block
74
+
75
+ Batch.run(inputs, to:, jobs:, force:) do |input, output|
76
+ pipeline = block.call(Retouch.open(input))
77
+ raise TypeError, "batch block must return a Retouch::Pipeline" unless pipeline.is_a?(Pipeline)
78
+
79
+ pipeline.save(output)
80
+ end
81
+ end
82
+ end
data/lib/retouch/cli.rb CHANGED
@@ -5,7 +5,43 @@ require "optparse"
5
5
 
6
6
  module Retouch
7
7
  class CLI
8
- OPERATIONS = %w[resize thumbnail crop flip flop rotate pad extend border trim].freeze
8
+ OPERATIONS = %w[
9
+ resize thumbnail crop flip flop rotate pad extend border trim
10
+ grayscale invert brightness contrast gamma saturate tint opacity quantize
11
+ blur sharpen pixelate overlay watermark text rect arrow
12
+ montage append spritesheet animate diff
13
+ ].freeze
14
+ MULTI_IMAGE_OPERATIONS = %w[montage append spritesheet animate diff].freeze
15
+ OPERATION_USAGE = {
16
+ "resize" => "GEOMETRY [--filter nearest|bilinear|bicubic|lanczos3] [--gravity POSITION]",
17
+ "thumbnail" => "GEOMETRY [--filter FILTER]",
18
+ "crop" => "GEOMETRY [--gravity POSITION]",
19
+ "rotate" => "DEGREES [--background COLOR]",
20
+ "pad" => "PIXELS [--color COLOR]",
21
+ "extend" => "WIDTH HEIGHT [--color COLOR] [--gravity POSITION]",
22
+ "border" => "PIXELS [COLOR]",
23
+ "trim" => "[--color COLOR] [--fuzz 0..255]",
24
+ "brightness" => "-255..255",
25
+ "contrast" => "FACTOR",
26
+ "gamma" => "GAMMA",
27
+ "saturate" => "FACTOR",
28
+ "tint" => "COLOR [--amount 0..1]",
29
+ "opacity" => "0..1",
30
+ "quantize" => "[--colors 2..256] [--dither none|ordered|floyd-steinberg]",
31
+ "blur" => "SIGMA",
32
+ "sharpen" => "SIGMA [--amount FACTOR]",
33
+ "pixelate" => "BLOCK_SIZE",
34
+ "overlay" => "IMAGE [--x X] [--y Y] [--gravity POSITION] [--opacity 0..1] [--mode MODE]",
35
+ "watermark" => "IMAGE [composite options]",
36
+ "text" => "TEXT [--at POSITION] [--size PIXELS] [--font FILE] [--background COLOR]",
37
+ "rect" => "X Y WIDTH HEIGHT [COLOR] [--fill]",
38
+ "arrow" => "X1 Y1 X2 Y2 [COLOR] [--width PIXELS] [--head PIXELS]",
39
+ "montage" => "[--cols N] [--gap PIXELS] [--background COLOR] [--label]",
40
+ "append" => "[--direction vertical|horizontal] [--gap PIXELS]",
41
+ "spritesheet" => "[--max-width PIXELS] [--gap PIXELS]",
42
+ "animate" => "(--fps RATE | --delay SECONDS) [--no-loop]",
43
+ "diff" => "[--threshold 0..255] [--color COLOR]"
44
+ }.freeze
9
45
 
10
46
  def self.run(argv, out: $stdout, err: $stderr)
11
47
  new(argv, out:, err:).run
@@ -18,9 +54,10 @@ module Retouch
18
54
  @args = argv.dup
19
55
  @out = out
20
56
  @err = err
21
- @options = { level: 6 }
57
+ @options = { level: 6, jobs: 1, loop: true }
22
58
  read_global_options
23
59
  raise ArgumentError, "PNG level must be between 0 and 9" unless @options[:level].between?(0, 9)
60
+ raise ArgumentError, "jobs must be positive" unless @options[:jobs].positive?
24
61
  end
25
62
 
26
63
  def run
@@ -32,25 +69,20 @@ module Retouch
32
69
  return fail_usage("missing operation") unless operation_at
33
70
 
34
71
  inputs = @args.shift(operation_at)
35
- return fail_usage("exactly one input file is required") unless inputs.one?
72
+ return fail_usage("at least one input file is required") if inputs.empty?
36
73
  return fail_usage("an output path with -o is required") unless @options[:output]
37
74
 
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]
75
+ operation = @args.first
76
+ parsed = parse_cli_operations(@args)
77
+ return 2 unless parsed
43
78
 
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)
79
+ if MULTI_IMAGE_OPERATIONS.include?(operation)
80
+ return fail_usage("#{operation} must be the only operation in its command") unless parsed.one?
81
+
82
+ multi_image_command(inputs, operation, parsed.fetch(0))
83
+ else
84
+ image_command(inputs, parsed)
48
85
  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
86
  rescue StandardError => e
55
87
  @err.puts("retouch: #{e.message}")
56
88
  @err.puts(e.backtrace.first) if @options[:verbose]
@@ -71,7 +103,10 @@ module Retouch
71
103
  when "--quiet" then @options[:quiet] = true
72
104
  when "--force" then @options[:force] = true
73
105
  when "--strip" then @options[:strip] = true
106
+ when "--loop" then @options[:loop] = true
107
+ when "--no-loop" then @options[:loop] = false
74
108
  when "--level" then @options[:level] = Integer(require_value(token))
109
+ when "--jobs" then @options[:jobs] = Integer(require_value(token))
75
110
  else remaining << token
76
111
  end
77
112
  end
@@ -92,19 +127,50 @@ module Retouch
92
127
  raise ArgumentError, "unknown operation: #{name}" unless OPERATIONS.include?(name)
93
128
 
94
129
  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 [[], {}]
130
+ when "resize", "thumbnail", "crop"
131
+ [[required(tokens, "geometry")], read_options(tokens, filter: :symbol, gravity: :symbol)]
132
+ when "rotate" then [[Float(required(tokens, name))], read_options(tokens, background: :color)]
133
+ when "pad" then [[Integer(required(tokens, name))], read_options(tokens, color: :color)]
134
+ when "extend" then [[Integer(required(tokens, name)), Integer(required(tokens, name))], read_options(tokens, color: :color, gravity: :symbol)]
135
+ when "border" then [[Integer(required(tokens, name)), tokens.first&.start_with?("#") ? tokens.shift : "#000000"], {}]
136
+ when "trim" then [[], read_options(tokens, fuzz: :integer, color: :color)]
137
+ when "brightness", "contrast", "gamma", "saturate", "opacity" then [[Float(required(tokens, name))], {}]
138
+ when "tint" then [[required(tokens, "color")], read_options(tokens, amount: :float)]
139
+ when "quantize" then [[], read_options(tokens, colors: :integer, dither: :symbol)]
140
+ when "blur", "sharpen" then [[Float(required(tokens, name))], read_options(tokens, amount: :float)]
141
+ when "pixelate" then [[Integer(required(tokens, name))], {}]
142
+ when "overlay", "watermark"
143
+ [[ImageIO.read(required(tokens, "overlay image"))], read_options(tokens, x: :integer, y: :integer, gravity: :symbol, opacity: :float, mode: :symbol)]
144
+ when "text"
145
+ [[required(tokens, "text")], read_options(tokens, x: :integer, y: :integer, at: :symbol, size: :integer, font: :string, color: :color, background: :color, padding: :integer)]
146
+ when "rect"
147
+ [[Integer(required(tokens, "x")), Integer(required(tokens, "y")), Integer(required(tokens, "width")), Integer(required(tokens, "height")), tokens.first&.start_with?("#") ? tokens.shift : "#ffffff"], read_options(tokens, fill: :flag)]
148
+ when "arrow"
149
+ [[Integer(required(tokens, "x1")), Integer(required(tokens, "y1")), Integer(required(tokens, "x2")), Integer(required(tokens, "y2")), tokens.first&.start_with?("#") ? tokens.shift : "#ffffff"], read_options(tokens, width: :integer, head: :float)]
150
+ when "montage" then [[], read_options(tokens, cols: :integer, gap: :integer, background: :color, label: :flag)]
151
+ when "append" then [[], read_options(tokens, direction: :symbol, gap: :integer, background: :color)]
152
+ when "spritesheet" then [[], read_options(tokens, max_width: :integer, gap: :integer, background: :color)]
153
+ when "animate"
154
+ options = read_options(tokens, fps: :float, delay: :float, colors: :integer, dither: :symbol)
155
+ raise OptionParser::InvalidArgument, "use either --fps or --delay" if options.key?(:fps) && options.key?(:delay)
156
+ raise OptionParser::MissingArgument, "--fps or --delay" unless options.key?(:fps) || options.key?(:delay)
157
+
158
+ [[], options]
159
+ when "diff" then [[], read_options(tokens, threshold: :float, color: :color)]
160
+ when "grayscale", "invert", "flip", "flop" then [[], {}]
102
161
  end
103
162
  operations << [name, args, options]
104
163
  end
105
164
  operations
106
165
  end
107
166
 
167
+ def parse_cli_operations(tokens)
168
+ parse_operations(tokens)
169
+ rescue ArgumentError, OptionParser::ParseError => e
170
+ @err.puts("retouch: #{e.message}")
171
+ nil
172
+ end
173
+
108
174
  def read_options(tokens, specification)
109
175
  options = {}
110
176
  while tokens.first&.start_with?("--")
@@ -112,11 +178,16 @@ module Retouch
112
178
  type = specification[option]
113
179
  raise OptionParser::InvalidOption, "--#{option}" unless type
114
180
 
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
181
+ options[option] = if type == :flag
182
+ true
183
+ else
184
+ value = required(tokens, option)
185
+ case type
186
+ when :integer then Integer(value)
187
+ when :float then Float(value)
188
+ when :symbol then value.tr("-", "_").to_sym
189
+ else value
190
+ end
120
191
  end
121
192
  end
122
193
  options
@@ -129,16 +200,96 @@ module Retouch
129
200
  value
130
201
  end
131
202
 
203
+ def image_command(inputs, operations)
204
+ expanded = expand_inputs(inputs)
205
+ return fail_usage("no input files matched") if expanded.empty?
206
+
207
+ batch = expanded.length > 1 || inputs.any? { |input| input.match?(/[?*{\[]/) } || @options[:output].include?("{")
208
+ if batch
209
+ return fail_usage("batch output must use a template such as {name}") if expanded.length > 1 && !@options[:output].include?("{")
210
+
211
+ outputs = Batch.run(expanded, to: @options[:output], jobs: @options[:jobs], force: @options[:force], dry_run: @options[:dry_run]) do |input, output|
212
+ apply_operations(Retouch.open(input), operations).save(output, level: @options[:level], strip: @options[:strip])
213
+ end
214
+ outputs.each { |path| announce(path) } if @options[:dry_run] || !@options[:quiet]
215
+ return 0
216
+ end
217
+
218
+ output = @options[:output]
219
+ return announce("retouch #{expanded.first} #{operations.map(&:first).join(" ")} -o #{output}") if @options[:dry_run]
220
+ raise Error, "input file does not exist: #{expanded.first}" unless File.file?(expanded.first)
221
+ raise Error, "refusing to overwrite #{output}; use --force" if File.exist?(output) && !@options[:force]
222
+
223
+ result = apply_operations(Retouch.open(expanded.first), operations)
224
+ FileUtils.mkdir_p(File.dirname(output)) unless File.dirname(output) == "."
225
+ result.save(output, level: @options[:level], strip: @options[:strip])
226
+ announce(output)
227
+ end
228
+
229
+ def multi_image_command(inputs, name, (_operation, _arguments, options))
230
+ paths = expand_inputs(inputs)
231
+ return fail_usage("no input files matched") if paths.empty?
232
+ return fail_usage("#{name} expects at least two images") if paths.length < 2
233
+ return fail_usage("diff expects exactly two images") if name == "diff" && paths.length != 2
234
+
235
+ output = @options[:output]
236
+ manifest = spritesheet_manifest_path(output) if name == "spritesheet"
237
+ outputs = name == "spritesheet" ? [output, manifest] : [output]
238
+ return announce("retouch #{paths.join(" ")} #{name} -o #{output}") if @options[:dry_run]
239
+ if !@options[:force] && (existing = outputs.find { |path| File.exist?(path) })
240
+ raise Error, "refusing to overwrite #{existing}; use --force"
241
+ end
242
+
243
+ FileUtils.mkdir_p(File.dirname(output)) unless File.dirname(output) == "."
244
+ images = paths.map { |path| ImageIO.read(path) }
245
+ case name
246
+ when "montage"
247
+ options[:labels] = paths.map { |path| File.basename(path) } if options.delete(:label)
248
+ Operations.montage(images, **options).then { |image| ImageIO.write(image, output, level: @options[:level], strip: @options[:strip]) }
249
+ when "append"
250
+ Operations.append(images, **options).then { |image| ImageIO.write(image, output, level: @options[:level], strip: @options[:strip]) }
251
+ when "spritesheet"
252
+ image, placements = Operations.spritesheet(images, **options)
253
+ ImageIO.write(image, output, level: @options[:level], strip: @options[:strip])
254
+ File.write(manifest, JSON.pretty_generate(placements))
255
+ when "animate"
256
+ Operations.animate(images, output, **options, loop: @options[:loop])
257
+ when "diff"
258
+ result = Operations.diff(images[0], images[1], **options)
259
+ ImageIO.write(result[:image], output, level: @options[:level], strip: @options[:strip])
260
+ announce(format("%.2f%% pixels differ", result[:rate] * 100))
261
+ end
262
+ announce(output)
263
+ end
264
+
265
+ def apply_operations(pipeline, operations)
266
+ operations.reduce(pipeline) do |current, (name, args, options)|
267
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC) if @options[:verbose]
268
+ result = current.public_send(name, *args, **options)
269
+ if started
270
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
271
+ announce(format("%<operation>s %<elapsed>.3fs", operation: name, elapsed:))
272
+ end
273
+ result
274
+ end
275
+ end
276
+
277
+ def expand_inputs(inputs)
278
+ inputs.flat_map do |input|
279
+ input.match?(/[?*{\[]/) ? Dir.glob(input) : input
280
+ end
281
+ end
282
+
283
+ def spritesheet_manifest_path(output)
284
+ File.extname(output).empty? ? "#{output}.json" : output.sub(%r{\.[^./]+\z}, ".json")
285
+ end
286
+
132
287
  def info_command
133
288
  @args.shift
134
289
  return fail_usage("info expects one input file") unless @args.one?
135
290
 
136
291
  path = @args.first
137
292
  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
293
  end
143
294
 
144
295
  def fail_usage(message)
@@ -156,19 +307,18 @@ module Retouch
156
307
  if operation
157
308
  raise ArgumentError, "unknown operation help: #{operation}" unless OPERATIONS.include?(operation)
158
309
 
159
- @out.puts("retouch INPUT #{operation} [ARGUMENTS] -o OUTPUT")
310
+ @out.puts("retouch INPUTS #{operation} #{OPERATION_USAGE.fetch(operation, "")} -o OUTPUT")
160
311
  return 0
161
312
  end
162
313
  @out.puts <<~HELP
163
- Retouch resizes and reshapes PNG, PPM, and BMP images.
314
+ Retouch edits PNG, PPM, and BMP images with a Ruby API and CLI.
164
315
 
165
- Usage: retouch INPUT OPERATION [ARGUMENTS] -o OUTPUT
166
- retouch help OPERATION
316
+ Usage: retouch INPUT [INPUT ...] OPERATION [ARGUMENTS] -o OUTPUT
167
317
  retouch info INPUT
318
+ retouch help OPERATION
168
319
 
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
320
+ Operations: #{OPERATIONS.join(" ")}
321
+ Global: -o PATH --force --dry-run --verbose --quiet --strip --level 0..9 --jobs N --loop --no-loop
172
322
  HELP
173
323
  0
174
324
  end
data/lib/retouch/io.rb CHANGED
@@ -12,7 +12,11 @@ module Retouch
12
12
  "PPM"
13
13
  elsif signature.start_with?("BM")
14
14
  "BMP"
15
+ elsif signature.start_with?("GIF87a", "GIF89a")
16
+ "GIF"
15
17
  end
18
+ return with_format(Operations.open_gif(path, frame: 0), format) if format == "GIF"
19
+
16
20
  with_format(Tessel.read(path), format || "unknown")
17
21
  rescue Tessel::UnsupportedError => e
18
22
  raise Error, "unsupported image format for #{path}: #{e.message}", cause: e
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Retouch
4
+ module Kernels
5
+ module_function
6
+
7
+ def quantize(image, colors: 256, dither: :none, palette: nil)
8
+ indices, palette = Tessel::Quantize.quantize(image, colors: Integer(colors), dither: dither.to_sym, palette:)
9
+ source = image.bytes
10
+ output = String.new(capacity: source.bytesize, encoding: Encoding::BINARY)
11
+ (image.width * image.height).times do |index|
12
+ red, green, blue = palette.fetch(indices.getbyte(index))
13
+ output << red << green << blue << source.getbyte((index * 4) + 3)
14
+ end
15
+ Tessel::Image.from_rgba(image.width, image.height, output, metadata: image.metadata)
16
+ end
17
+ end
18
+ end