retouch 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,7 +43,7 @@ module Retouch
43
43
  Tessel::Image.from_rgba(result.width, result.height, result.bytes)
44
44
  end
45
45
 
46
- def crop(image, geometry, gravity: :north_west)
46
+ def crop(image, geometry, gravity: :north_west, **_options)
47
47
  geometry = Geometry.parse(geometry) unless geometry.is_a?(Geometry)
48
48
  width, height = geometry.crop_size(image.width, image.height)
49
49
  x, y = if geometry.offset_x.zero? && geometry.offset_y.zero?
@@ -89,7 +89,7 @@ module Retouch
89
89
  dy = y - target_cy
90
90
  sx = (cosine * dx) + (sine * dy) + source_cx
91
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)
92
+ output[x, y] = sample_bilinear(image, sx, sy, background) if sx.between?(0, image.width - 1) && sy >= 0 && sy <= image.height - 1
93
93
  end
94
94
  end
95
95
  output
@@ -112,7 +112,7 @@ module Retouch
112
112
  Tessel::Image.new(width, height, fill: color, metadata: image.metadata).tap { |canvas| canvas.blit(image, x, y) }
113
113
  end
114
114
 
115
- def border(image, width, color = "#000000")
115
+ def border(image, width, color = "#000000", **_options)
116
116
  width = Integer(width)
117
117
  raise ArgumentError, "border width must be positive" unless width.positive?
118
118
 
@@ -141,6 +141,299 @@ module Retouch
141
141
  copy_metadata(image.crop(bounds[0], bounds[1], bounds[2] - bounds[0] + 1, bounds[3] - bounds[1] + 1), image)
142
142
  end
143
143
 
144
+ def grayscale(image)
145
+ map_rgb(image) do |r, g, b|
146
+ v = ((0.2126 * r) + (0.7152 * g) + (0.0722 * b)).round
147
+ [v, v, v]
148
+ end
149
+ end
150
+
151
+ def invert(image) = map_lut(image, (0..255).map { |value| 255 - value })
152
+
153
+ def brightness(image, amount)
154
+ offset = Float(amount)
155
+ offset *= 255 if offset.abs <= 1
156
+ map_lut(image, (0..255).map { |value| (value + offset).round.clamp(0, 255) })
157
+ end
158
+
159
+ def contrast(image, amount)
160
+ factor = Float(amount)
161
+ factor = (1.0 + factor) if factor.abs <= 1
162
+ map_lut(image, (0..255).map { |value| (((value - 127.5) * factor) + 127.5).round.clamp(0, 255) })
163
+ end
164
+
165
+ def gamma(image, value)
166
+ gamma = Float(value)
167
+ raise ArgumentError, "gamma must be positive" unless gamma.positive?
168
+
169
+ lut = (0..255).map { |v| (255 * ((v / 255.0)**(1.0 / gamma))).round }
170
+ map_lut(image, lut)
171
+ end
172
+
173
+ def saturate(image, amount)
174
+ factor = Float(amount)
175
+ map_rgb(image) do |r, g, b|
176
+ gray = (0.2126 * r) + (0.7152 * g) + (0.0722 * b)
177
+ [r, g, b].map { |v| (gray + ((v - gray) * factor)).round.clamp(0, 255) }
178
+ end
179
+ end
180
+
181
+ def tint(image, color, amount: 0.5)
182
+ tint = Tessel::Color.pack(color).bytes
183
+ amount = Float(amount)
184
+ raise ArgumentError, "tint amount must be between 0 and 1" unless amount.between?(0, 1)
185
+
186
+ map_rgb(image) { |r, g, b| [r, g, b].zip(tint.first(3)).map { |a, c| ((a * (1 - amount)) + (c * amount)).round } }
187
+ end
188
+
189
+ def opacity(image, amount)
190
+ factor = Float(amount)
191
+ factor /= 100 if factor > 1
192
+ raise ArgumentError, "opacity must be between 0 and 1 (or 0 and 100)" unless factor.between?(0, 1)
193
+
194
+ map_rgba(image) { |r, g, b, a| [r, g, b, (a * factor).round] }
195
+ end
196
+
197
+ def quantize(image, colors: 256, dither: :none)
198
+ indices, palette = Tessel::Quantize.quantize(image, colors: Integer(colors), dither: dither.to_sym)
199
+ bytes = image.bytes
200
+ output = Tessel::Image.new(image.width, image.height, metadata: image.metadata)
201
+ indices.bytes.each_with_index do |index, pixel|
202
+ r, g, b = palette[index]
203
+ output[pixel % image.width, pixel / image.width] = [r, g, b, bytes.getbyte((pixel * 4) + 3)]
204
+ end
205
+ output
206
+ end
207
+
208
+ def blur(image, sigma: 1.0)
209
+ sigma = Float(sigma)
210
+ raise ArgumentError, "sigma must be positive and at most 100" unless sigma.positive? && sigma <= 100
211
+
212
+ radius = (sigma * 3).ceil
213
+ weights = (-radius..radius).map { |n| Math.exp(-(n * n) / (2 * sigma * sigma)) }
214
+ total = weights.sum
215
+ weights.map! { |weight| weight / total }
216
+ convolve(convolve(image, weights, horizontal: true), weights, horizontal: false)
217
+ end
218
+
219
+ def sharpen(image, amount: 1.0, sigma: 1.0)
220
+ blurred = blur(image, sigma:)
221
+ original = image.bytes
222
+ soft = blurred.bytes
223
+ result = String.new(capacity: original.bytesize, encoding: Encoding::BINARY)
224
+ original.bytesize.times do |i|
225
+ result << (i % 4 == 3 ? original.getbyte(i) : (original.getbyte(i) + (Float(amount) * (original.getbyte(i) - soft.getbyte(i)))).round.clamp(0, 255))
226
+ end
227
+ Tessel::Image.from_rgba(image.width, image.height, result, metadata: image.metadata)
228
+ end
229
+
230
+ def pixelate(image, size)
231
+ size = Integer(size)
232
+ raise ArgumentError, "pixel size must be positive" unless size.positive?
233
+
234
+ output = image.dup
235
+ (0...image.height).step(size) do |y|
236
+ (0...image.width).step(size) do |x|
237
+ sw = [size, image.width - x].min
238
+ sh = [size, image.height - y].min
239
+ sums = [0.0, 0.0, 0.0, 0.0]
240
+ (y...(y + sh)).each do |row|
241
+ (x...(x + sw)).each do |col|
242
+ red, green, blue, alpha = image[col, row]
243
+ sums[0] += red * alpha / 255.0
244
+ sums[1] += green * alpha / 255.0
245
+ sums[2] += blue * alpha / 255.0
246
+ sums[3] += alpha
247
+ end
248
+ end
249
+ alpha = (sums[3] / (sw * sh)).round
250
+ color = if sums[3].positive?
251
+ [*sums.first(3).map { |value| (value * 255 / sums[3]).round }, alpha]
252
+ else
253
+ [0, 0, 0, 0]
254
+ end
255
+ output.fill_rect(x, y, sw, sh, color)
256
+ end
257
+ end
258
+ output
259
+ end
260
+
261
+ def overlay(image, source, x: nil, y: nil, gravity: :center, opacity: 1.0, blend: :normal)
262
+ source = source.to_image if source.is_a?(Pipeline)
263
+ raise TypeError, "overlay must be a Tessel::Image or Retouch::Pipeline" unless source.is_a?(Tessel::Image)
264
+
265
+ default_x, default_y = Gravity.offset(image.width, image.height, source.width, source.height, gravity)
266
+ x ||= default_x
267
+ y ||= default_y
268
+ opacity = Float(opacity)
269
+ opacity /= 100 if opacity > 1
270
+ raise ArgumentError, "opacity must be between 0 and 1" unless opacity.between?(0, 1)
271
+
272
+ output = image.dup
273
+ source.height.times do |sy|
274
+ source.width.times do |sx|
275
+ dx = Integer(x) + sx
276
+ dy = Integer(y) + sy
277
+ next unless dx.between?(0, image.width - 1) && dy.between?(0, image.height - 1)
278
+
279
+ source_pixel = source[sx, sy]
280
+ next if source_pixel[3].zero?
281
+
282
+ source_pixel[3] = (source_pixel[3] * opacity).round
283
+ output[dx, dy] = composite_pixel(output[dx, dy], source_pixel, blend.to_sym)
284
+ end
285
+ end
286
+ output
287
+ end
288
+
289
+ def watermark(image, source, gravity: :south_east, opacity: 0.5, **options)
290
+ overlay(image, source, gravity:, opacity:, **options)
291
+ end
292
+
293
+ def text(image, value, at: :north_west, x: nil, y: nil, size: 16, color: "#ffffff", font: nil, background: nil, padding: 4, **_options)
294
+ padding = Integer(padding)
295
+ raise ArgumentError, "padding must not be negative" if padding.negative?
296
+
297
+ font = font_for(font, size, "text")
298
+ width, height = font.measure(String(value))
299
+ x ||= Gravity.offset(image.width, image.height, width + (padding * 2), height + (padding * 2), at).first
300
+ y ||= Gravity.offset(image.width, image.height, width + (padding * 2), height + (padding * 2), at).last
301
+ output = image.dup
302
+ output.fill_rect(x, y, width + (padding * 2), height + (padding * 2), background) if background
303
+ font.draw(output, x + padding, y + padding, String(value), color:)
304
+ output
305
+ end
306
+
307
+ def rect(image, geometry, color: "#ffffff", fill: false, width: 1)
308
+ geometry = Geometry.parse(geometry) unless geometry.is_a?(Geometry)
309
+ x = geometry.offset_x
310
+ y = geometry.offset_y
311
+ w, h = geometry.crop_size(image.width, image.height)
312
+ output = image.dup
313
+ if fill
314
+ output.fill_rect(x, y, w, h, color, blend: :alpha)
315
+ else
316
+ width = Integer(width)
317
+ raise ArgumentError, "stroke width must be positive" unless width.positive?
318
+
319
+ output.fill_rect(x, y, w, width, color, blend: :alpha)
320
+ output.fill_rect(x, y + h - width, w, width, color, blend: :alpha)
321
+ output.fill_rect(x, y, width, h, color, blend: :alpha)
322
+ output.fill_rect(x + w - width, y, width, h, color, blend: :alpha)
323
+ end
324
+ output
325
+ end
326
+
327
+ def arrow(image, x1, y1, x2, y2, color: "#ffffff", width: 2, head: 10)
328
+ output = image.dup
329
+ x1, y1, x2, y2, width, head = [x1, y1, x2, y2, width, head].map { |v| Float(v) }
330
+ raise ArgumentError, "arrow width and head must be positive" unless width.positive? && head.positive?
331
+
332
+ draw_line(output, x1, y1, x2, y2, color, width)
333
+ angle = Math.atan2(y2 - y1, x2 - x1)
334
+ [angle + 2.6, angle - 2.6].each do |side|
335
+ draw_line(output, x2, y2, x2 - (head * Math.cos(side)), y2 - (head * Math.sin(side)), color, width)
336
+ end
337
+ output
338
+ end
339
+
340
+ def montage(images, cols: 3, gap: 0, background: "#ffffff", label: false, labels: nil, font: nil)
341
+ images = images.map { |image| image.is_a?(Pipeline) ? image.to_image : image }
342
+ raise ArgumentError, "montage needs at least one image" if images.empty?
343
+
344
+ cols = Integer(cols)
345
+ gap = Integer(gap)
346
+ raise ArgumentError, "cols must be positive and gap non-negative" unless cols.positive? && gap >= 0
347
+
348
+ if label
349
+ font = font_for(font, 12, "montage labels")
350
+ labels ||= images.each_index.map(&:to_s)
351
+ raise ArgumentError, "montage needs one label per image" unless labels.length == images.length
352
+ end
353
+ cell_width = images.map(&:width).max
354
+ label_height = label ? font.line_height + 4 : 0
355
+ cell_height = images.map(&:height).max + label_height
356
+ rows = (images.length.to_f / cols).ceil
357
+ output = Tessel::Image.new((cols * cell_width) + ((cols - 1) * gap), (rows * cell_height) + ((rows - 1) * gap), fill: background)
358
+ images.each_with_index do |image, index|
359
+ col = index % cols
360
+ row = index / cols
361
+ x = (col * (cell_width + gap)) + ((cell_width - image.width) / 2)
362
+ y = (row * (cell_height + gap)) + ((cell_height - label_height - image.height) / 2)
363
+ output.blit(image, x, y)
364
+ font.draw(output, col * (cell_width + gap), y + image.height + 2, String(labels[index]), color: "#000000") if label
365
+ end
366
+ output
367
+ end
368
+
369
+ def append(images, direction: :horizontal, gap: 0, background: [0, 0, 0, 0])
370
+ images = images.map { |image| image.is_a?(Pipeline) ? image.to_image : image }
371
+ raise ArgumentError, "append needs at least one image" if images.empty?
372
+
373
+ gap = Integer(gap)
374
+ raise ArgumentError, "gap must not be negative" if gap.negative?
375
+
376
+ raise ArgumentError, "direction must be :horizontal or :vertical" unless %i[horizontal vertical].include?(direction.to_sym)
377
+
378
+ horizontal = direction.to_sym == :horizontal
379
+ width = horizontal ? images.sum(&:width) + (gap * (images.length - 1)) : images.map(&:width).max
380
+ height = horizontal ? images.map(&:height).max : images.sum(&:height) + (gap * (images.length - 1))
381
+ output = Tessel::Image.new(width, height, fill: background)
382
+ offset = 0
383
+ images.each do |image|
384
+ x, y = horizontal ? [offset, (height - image.height) / 2] : [(width - image.width) / 2, offset]
385
+ output.blit(image, x, y)
386
+ offset += (horizontal ? image.width : image.height) + gap
387
+ end
388
+ output
389
+ end
390
+
391
+ def spritesheet(images, cols: nil, gap: 0, background: [0, 0, 0, 0])
392
+ images = images.map { |image| image.is_a?(Pipeline) ? image.to_image : image }
393
+ raise ArgumentError, "spritesheet needs at least one image" if images.empty?
394
+
395
+ gap = Integer(gap)
396
+ raise ArgumentError, "gap must not be negative" if gap.negative?
397
+
398
+ if cols
399
+ cols = Integer(cols)
400
+ raise ArgumentError, "cols must be positive" unless cols.positive?
401
+
402
+ cell_width = images.map(&:width).max
403
+ cell_height = images.map(&:height).max
404
+ positions = images.each_index.map do |i|
405
+ [(i % cols) * (cell_width + gap), (i / cols) * (cell_height + gap)]
406
+ end
407
+ columns = [cols, images.length].min
408
+ width = (columns * cell_width) + ((columns - 1) * gap)
409
+ height = ((images.length.to_f / cols).ceil * cell_height) + (((images.length.to_f / cols).ceil - 1) * gap)
410
+ else
411
+ positions, width, height = pack_sprites(images, gap)
412
+ end
413
+ sheet = Tessel::Image.new(width, height, fill: background)
414
+ images.each_with_index { |image, i| sheet.blit(image, *positions[i]) }
415
+ frames = images.each_with_index.to_h do |image, i|
416
+ [i.to_s, { "x" => positions[i][0], "y" => positions[i][1], "width" => image.width, "height" => image.height }]
417
+ end
418
+ [sheet, { "frames" => frames, "meta" => { "size" => { "w" => sheet.width, "h" => sheet.height }, "scale" => "1" } }]
419
+ end
420
+
421
+ def animate(images, path, fps: nil, delay: nil, loop: true, format: nil, **options)
422
+ require_optional("flipbook", "animate") { require "flipbook" }
423
+ Flipbook.write(path, images.map { |image| image.is_a?(Pipeline) ? image.to_image : image }, fps:, delay:, loop:, format:, **options)
424
+ end
425
+
426
+ def diff(expected, actual, max_delta: 0)
427
+ expected = expected.to_image if expected.is_a?(Pipeline)
428
+ actual = actual.to_image if actual.is_a?(Pipeline)
429
+ require_optional("lookalike", "diff") { require "lookalike" }
430
+ comparison = Lookalike.compare(expected, actual, max_delta:)
431
+ diff = comparison.diff_image || Tessel::Image.new(expected.width, expected.height)
432
+ ratio = comparison.diff_pixels.to_f / ([expected.width, actual.width].max * [expected.height, actual.height].max)
433
+ metadata = diff.metadata.merge("diff_pixels" => comparison.diff_pixels.to_s, "diff_ratio" => ratio.to_s)
434
+ Tessel::Image.from_rgba(diff.width, diff.height, diff.bytes, metadata:)
435
+ end
436
+
144
437
  def resample(image, width, height, filter)
145
438
  filter = filter.to_sym
146
439
  raise ArgumentError, "unknown resize filter: #{filter}" unless %i[nearest bilinear bicubic lanczos3].include?(filter)
@@ -161,7 +454,7 @@ module Retouch
161
454
  sums[2] += source.getbyte(offset + 2) * alpha * weight
162
455
  sums[3] += source.getbyte(offset + 3) * weight
163
456
  end
164
- sums.each { |value| intermediate << value.round.clamp(0, 255) }
457
+ sums.each { |v| intermediate << v.round.clamp(0, 255) }
165
458
  end
166
459
  end
167
460
  result = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
@@ -206,20 +499,88 @@ module Retouch
206
499
  end
207
500
  end
208
501
 
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
502
+ def cubic(x)
503
+ x = x.abs
504
+ return (((1.5 * x) - 2.5) * x * x) + 1 if x < 1
505
+ return (((((-0.5 * x) + 2.5) * x) - 4) * x) + 2 if x < 2
213
506
 
214
507
  0.0
215
508
  end
216
509
 
217
- def lanczos(value)
218
- value = value.abs
219
- return 1.0 if value.zero?
220
- return 0.0 if value >= 3
510
+ def lanczos(x)
511
+ x = x.abs
512
+ return 1.0 if x.zero?
513
+ return 0.0 if x >= 3
221
514
 
222
- Math.sin(Math::PI * value) * Math.sin(Math::PI * value / 3) * 3 / (Math::PI * Math::PI * value * value)
515
+ Math.sin(Math::PI * x) * Math.sin(Math::PI * x / 3) * 3 / (Math::PI * Math::PI * x * x)
516
+ end
517
+
518
+ def convolve(image, weights, horizontal:)
519
+ radius = weights.length / 2
520
+ source = image.bytes.unpack("C*")
521
+ output = String.new(capacity: image.width * image.height * 4, encoding: Encoding::BINARY)
522
+ span = horizontal ? image.width : image.height
523
+ contributions = Array.new(span) do |position|
524
+ weights.each_index.map { |tap| (position + tap - radius).clamp(0, span - 1) }
525
+ end
526
+ y = 0
527
+ while y < image.height
528
+ x = 0
529
+ while x < image.width
530
+ red = 0.0
531
+ green = 0.0
532
+ blue = 0.0
533
+ alpha_sum = 0.0
534
+ taps = contributions[horizontal ? x : y]
535
+ i = 0
536
+ while i < weights.length
537
+ offset = horizontal ? ((y * image.width) + taps[i]) * 4 : ((taps[i] * image.width) + x) * 4
538
+ alpha = source[offset + 3] / 255.0
539
+ weight = weights[i]
540
+ red += source[offset] * alpha * weight
541
+ green += source[offset + 1] * alpha * weight
542
+ blue += source[offset + 2] * alpha * weight
543
+ alpha_sum += source[offset + 3] * weight
544
+ i += 1
545
+ end
546
+ alpha = alpha_sum.round.clamp(0, 255)
547
+ if alpha.positive?
548
+ output << (red * 255 / alpha).round.clamp(0, 255)
549
+ output << (green * 255 / alpha).round.clamp(0, 255)
550
+ output << (blue * 255 / alpha).round.clamp(0, 255)
551
+ else
552
+ output << "\0\0\0".b
553
+ end
554
+ output << alpha
555
+ x += 1
556
+ end
557
+ y += 1
558
+ end
559
+ Tessel::Image.from_rgba(image.width, image.height, output, metadata: image.metadata)
560
+ end
561
+
562
+ def map_rgba(image)
563
+ output = String.new(capacity: image.width * image.height * 4, encoding: Encoding::BINARY)
564
+ image.bytes.bytes.each_slice(4) { |rgba| yield(*rgba).each { |v| output << Integer(v).clamp(0, 255) } }
565
+ Tessel::Image.from_rgba(image.width, image.height, output, metadata: image.metadata)
566
+ end
567
+
568
+ def map_rgb(image, &block)
569
+ map_rgba(image) { |r, g, b, a| [*block.call(r, g, b), a] }
570
+ end
571
+
572
+ def map_lut(image, lookup)
573
+ source = image.bytes
574
+ output = String.new(capacity: source.bytesize, encoding: Encoding::BINARY)
575
+ offset = 0
576
+ while offset < source.bytesize
577
+ output << lookup[source.getbyte(offset)]
578
+ output << lookup[source.getbyte(offset + 1)]
579
+ output << lookup[source.getbyte(offset + 2)]
580
+ output << source.getbyte(offset + 3)
581
+ offset += 4
582
+ end
583
+ Tessel::Image.from_rgba(image.width, image.height, output, metadata: image.metadata)
223
584
  end
224
585
 
225
586
  def copy_metadata(output, source)
@@ -252,13 +613,113 @@ module Retouch
252
613
  pixels.each do |px, py, weight|
253
614
  color = image[px.clamp(0, image.width - 1), py.clamp(0, image.height - 1)] || Tessel::Color.pack(background).bytes
254
615
  alpha = color[3] / 255.0
255
- 3.times { |channel| sums[channel] += color[channel] * alpha * weight }
616
+ 3.times { |c| sums[c] += color[c] * alpha * weight }
256
617
  sums[3] += color[3] * weight
257
618
  end
258
619
  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]
620
+ [*(alpha.positive? ? sums.first(3).map { |v| (v * 255 / alpha).round.clamp(0, 255) } : [0, 0, 0]), alpha]
621
+ end
622
+
623
+ def composite_pixel(destination, source, mode)
624
+ raise ArgumentError, "unknown blend mode: #{mode}" unless %i[normal multiply screen overlay darken lighten add].include?(mode)
625
+
626
+ sa = source[3] / 255.0
627
+ da = destination[3] / 255.0
628
+ out_a = sa + (da * (1 - sa))
629
+ return [0, 0, 0, 0] if out_a.zero?
630
+
631
+ rgb = 3.times.map do |c|
632
+ s = source[c]
633
+ d = destination[c]
634
+ mixed = case mode
635
+ when :normal then s
636
+ when :multiply then s * d / 255.0
637
+ when :screen then 255 - ((255 - s) * (255 - d) / 255.0)
638
+ when :overlay then d < 128 ? 2 * s * d / 255.0 : 255 - (2 * (255 - s) * (255 - d) / 255.0)
639
+ when :darken then [s, d].min
640
+ when :lighten then [s, d].max
641
+ when :add then [s + d, 255].min
642
+ end
643
+ source_only = (1 - da) * sa * s
644
+ destination_only = (1 - sa) * da * d
645
+ overlap = sa * da * mixed
646
+ ((source_only + destination_only + overlap) / out_a).round.clamp(0, 255)
647
+ end
648
+ [*rgb, (out_a * 255).round]
260
649
  end
261
650
 
262
- private_class_method :resample, :axis_weights, :cubic, :lanczos, :copy_metadata, :rotate_quarter, :sample_bilinear
651
+ def draw_line(image, x1, y1, x2, y2, color, width)
652
+ steps = [(x2 - x1).abs, (y2 - y1).abs].max.ceil
653
+ radius = [width / 2.0, 0.5].max
654
+ (0..steps).each do |n|
655
+ t = steps.zero? ? 0 : n.to_f / steps
656
+ x = (x1 + ((x2 - x1) * t)).round
657
+ y = (y1 + ((y2 - y1) * t)).round
658
+ image.fill_rect((x - radius).floor, (y - radius).floor, radius.ceil * 2, radius.ceil * 2, color, blend: :alpha)
659
+ end
660
+ end
661
+
662
+ def font_for(font, size, operation)
663
+ require_optional("glyphic", operation) { require "glyphic" }
664
+ font ||= ENV.fetch("RETOUCH_FONT", nil)
665
+ raise ArgumentError, "#{operation} needs font: or RETOUCH_FONT pointing to a BDF/TTF font" unless font
666
+ return font if font.respond_to?(:draw)
667
+ return Glyphic::BDF.load(font) if File.extname(font).downcase == ".bdf"
668
+
669
+ Glyphic::TrueType.load(font, size: Integer(size))
670
+ end
671
+
672
+ def pack_sprites(images, gap)
673
+ sizes = images.map { |image| [image.width + gap, image.height + gap] }
674
+ bin_width = [sizes.map(&:first).max, Math.sqrt(sizes.sum { |w, h| w * h }).ceil].max
675
+ free = [[0, 0, bin_width, sizes.sum(&:last)]]
676
+ positions = Array.new(images.length)
677
+ order = images.each_index.sort_by { |i| [-[sizes[i][0], sizes[i][1]].max, -(sizes[i][0] * sizes[i][1]), i] }
678
+ order.each do |index|
679
+ width, height = sizes[index]
680
+ choice = free.filter_map do |x, y, free_width, free_height|
681
+ next if width > free_width || height > free_height
682
+
683
+ leftover_width = free_width - width
684
+ leftover_height = free_height - height
685
+ [[[leftover_width, leftover_height].min, [leftover_width, leftover_height].max, y, x], [x, y, width, height]]
686
+ end.min_by(&:first)
687
+ raise Error, "could not pack sprite #{index}" unless choice
688
+
689
+ placed = choice.last
690
+ positions[index] = placed.first(2)
691
+ free = free.flat_map { |rect| split_free_rect(rect, placed) }
692
+ free.uniq!
693
+ free.reject! { |rect| free.any? { |other| other != rect && contains_rect?(other, rect) } }
694
+ end
695
+ width = images.each_index.map { |i| positions[i][0] + images[i].width }.max
696
+ height = images.each_index.map { |i| positions[i][1] + images[i].height }.max
697
+ [positions, width, height]
698
+ end
699
+
700
+ def split_free_rect(free, placed)
701
+ x, y, width, height = free
702
+ px, py, pwidth, pheight = placed
703
+ return [free] if px >= x + width || px + pwidth <= x || py >= y + height || py + pheight <= y
704
+
705
+ result = []
706
+ result << [x, y, px - x, height] if px > x
707
+ result << [px + pwidth, y, x + width - px - pwidth, height] if px + pwidth < x + width
708
+ result << [x, y, width, py - y] if py > y
709
+ result << [x, py + pheight, width, y + height - py - pheight] if py + pheight < y + height
710
+ result.select { |_, _, w, h| w.positive? && h.positive? }
711
+ end
712
+
713
+ def contains_rect?(outer, inner)
714
+ x, y, width, height = outer
715
+ ix, iy, iwidth, iheight = inner
716
+ ix >= x && iy >= y && ix + iwidth <= x + width && iy + iheight <= y + height
717
+ end
718
+
719
+ def require_optional(gem, operation)
720
+ yield
721
+ rescue LoadError => e
722
+ raise Error, "#{operation} requires the #{gem} gem", cause: e
723
+ end
263
724
  end
264
725
  end
@@ -2,7 +2,11 @@
2
2
 
3
3
  module Retouch
4
4
  class Pipeline
5
- OPERATIONS = %i[resize thumbnail crop flip flop rotate pad extend border trim].freeze
5
+ OPERATIONS = %i[
6
+ resize thumbnail crop flip flop rotate pad extend border trim grayscale invert
7
+ brightness contrast gamma saturate tint opacity quantize blur sharpen pixelate
8
+ overlay watermark text rect arrow
9
+ ].freeze
6
10
 
7
11
  def initialize(image, operations = [])
8
12
  raise TypeError, "image must be a Tessel::Image" unless image.is_a?(Tessel::Image)
@@ -14,6 +18,7 @@ module Retouch
14
18
  def to_image
15
19
  @operations.reduce(@source.dup) do |image, (name, args, options)|
16
20
  args = args.dup
21
+ args[0] = ImageIO.read(args[0]) if %i[overlay watermark].include?(name) && args[0].is_a?(String)
17
22
  Operations.apply(image, name, *args, **options)
18
23
  end
19
24
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Retouch
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/retouch.rb CHANGED
@@ -7,17 +7,22 @@ require_relative "retouch/geometry"
7
7
  require_relative "retouch/gravity"
8
8
  require_relative "retouch/operations"
9
9
  require_relative "retouch/pipeline"
10
+ require_relative "retouch/batch"
10
11
  require_relative "retouch/cli"
11
12
  require_relative "retouch/io"
12
13
 
13
14
  module Retouch
14
15
  class Error < StandardError; end
15
16
 
16
- def self.open(path)
17
- Pipeline.new(ImageIO.read(path))
17
+ def self.open(path, frame: 0)
18
+ Pipeline.new(ImageIO.read(path, frame:))
18
19
  end
19
20
 
20
21
  def self.from_image(image)
21
22
  Pipeline.new(image)
22
23
  end
24
+
25
+ def self.batch(pattern, to:, force: false, jobs: 1, level: 6, strip: false, &)
26
+ Batch.run(pattern, to:, force:, jobs:, level:, strip:, &)
27
+ end
23
28
  end
data/retouch.gemspec CHANGED
@@ -7,8 +7,8 @@ Gem::Specification.new do |spec|
7
7
  spec.version = Retouch::VERSION
8
8
  spec.authors = ["Yudai Takada"]
9
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."
10
+ spec.summary = "Small Ruby image editing tools for PNG, PPM, and BMP"
11
+ spec.description = "A pure Ruby image editor and command line tool built on Tessel, with optional animation, text, and visual diff integrations."
12
12
  spec.homepage = "https://github.com/rbgfx/retouch"
13
13
  spec.license = "MIT"
14
14
  spec.required_ruby_version = ">= 3.1.0"
data/sig/retouch.rbs CHANGED
@@ -30,14 +30,28 @@ module Retouch
30
30
  def flip: -> Pipeline
31
31
  def flop: -> Pipeline
32
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
33
  def border: (Integer width, ?String color, **untyped options) -> Pipeline
36
- def trim: (**untyped options) -> Pipeline
34
+ def grayscale: -> Pipeline
35
+ def invert: -> Pipeline
36
+ def brightness: (Numeric amount) -> Pipeline
37
+ def contrast: (Numeric amount) -> Pipeline
38
+ def gamma: (Numeric amount) -> Pipeline
39
+ def saturate: (Numeric amount) -> Pipeline
40
+ def tint: (String color, **untyped options) -> Pipeline
41
+ def opacity: (Numeric amount) -> Pipeline
42
+ def blur: (**untyped options) -> Pipeline
43
+ def sharpen: (**untyped options) -> Pipeline
44
+ def pixelate: (Integer size) -> Pipeline
45
+ def overlay: (String | untyped image, **untyped options) -> Pipeline
46
+ def watermark: (String | untyped image, **untyped options) -> Pipeline
47
+ def text: (String text, **untyped options) -> Pipeline
48
+ def rect: (String geometry, **untyped options) -> Pipeline
49
+ def arrow: (Numeric x1, Numeric y1, Numeric x2, Numeric y2, **untyped options) -> Pipeline
37
50
  end
38
51
 
39
- def self.open: (String path) -> Pipeline
52
+ def self.open: (String path, ?frame: Integer) -> Pipeline
40
53
  def self.from_image: (untyped image) -> Pipeline
54
+ def self.batch: (String | Array[String] pattern, to: String, ?force: bool, ?jobs: Integer, ?level: Integer, ?strip: bool) { (Pipeline) -> Pipeline } -> Array[String]
41
55
 
42
56
  class CLI
43
57
  def self.run: (Array[String] argv, ?out: untyped, ?err: untyped) -> Integer