Module: Retouch::Operations

Defined in:
lib/retouch/operations.rb,
lib/retouch/operations/draw.rb,
lib/retouch/operations/color.rb,
lib/retouch/operations/filter.rb,
lib/retouch/operations/multiple.rb,
sig/retouch.rbs

Constant Summary collapse

WEIGHT_SCALE =
1 << 20
BLENDS =
%i[normal multiply screen overlay darken lighten add].freeze

Class Method Summary collapse

Class Method Details

.animate(frames, path, fps: nil, delay: nil, loop: true, colors: 256, palette: :global, dither: :floyd_steinberg) ⇒ String

Parameters:

  • frames (Array[untyped])
  • path (String)
  • fps: (Numeric, nil) (defaults to: nil)
  • delay: (Numeric, Array[Numeric], nil) (defaults to: nil)
  • loop: (bool, Integer) (defaults to: true)
  • colors: (Integer) (defaults to: 256)
  • palette: (Symbol, Array[Array[Integer]]) (defaults to: :global)
  • dither: (Symbol) (defaults to: :floyd_steinberg)

Returns:

  • (String)


133
134
135
136
# File 'lib/retouch/operations/multiple.rb', line 133

def animate(frames, path, fps: nil, delay: nil, loop: true, colors: 256, palette: :global, dither: :floyd_steinberg)
  require_flipbook
  Flipbook.write(path, validate_images(frames), fps:, delay:, loop:, colors:, palette:, dither:)
end

.append(images, direction: :vertical, gap: 0, background: [0, 0, 0, 0]) ⇒ Object

Parameters:

  • images (Array[untyped])
  • direction: (Symbol) (defaults to: :vertical)
  • gap: (Integer) (defaults to: 0)
  • background: (Object) (defaults to: [0, 0, 0, 0])

Returns:

  • (Object)

Raises:

  • (ArgumentError)


7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# File 'lib/retouch/operations/multiple.rb', line 7

def append(images, direction: :vertical, gap: 0, background: [0, 0, 0, 0])
  images = validate_images(images)
  direction = direction.to_sym
  gap = Integer(gap)
  raise ArgumentError, "direction must be vertical or horizontal" unless %i[vertical horizontal].include?(direction)
  raise ArgumentError, "gap must not be negative" if gap.negative?

  if direction == :vertical
    width = images.map(&:width).max
    height = images.sum(&:height) + (gap * (images.length - 1))
  else
    width = images.sum(&:width) + (gap * (images.length - 1))
    height = images.map(&:height).max
  end
  output = Tessel::Image.new(width, height, fill: background)
  x = y = 0
  images.each do |image|
    dx, dy = Gravity.offset(width, height, image.width, image.height, :center)
    output.blit(image, direction == :vertical ? dx : x, direction == :vertical ? y : dy)
    direction == :vertical ? y += image.height + gap : x += image.width + gap
  end
  output
end

.apply(image, name, *arguments, **options) ⇒ Object

Raises:

  • (TypeError)


9
10
11
12
13
14
15
16
# File 'lib/retouch/operations.rb', line 9

def apply(image, name, *arguments, **options)
  raise TypeError, "image must be a Tessel::Image" unless image.is_a?(Tessel::Image)

  method = name.to_sym
  raise ArgumentError, "unknown operation: #{name}" unless respond_to?(method)

  public_send(method, image, *arguments, **options)
end

.arrow(image, x1, y1, x2, y2, color = "#ffffff", width: 1, head: 8) ⇒ Object

Parameters:

  • image (Object)
  • x1 (Integer)
  • y1 (Integer)
  • x2 (Integer)
  • y2 (Integer)
  • color (String) (defaults to: "#ffffff")
  • width: (Integer) (defaults to: 1)
  • head: (Numeric) (defaults to: 8)

Returns:

  • (Object)

Raises:

  • (ArgumentError)


59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/retouch/operations/draw.rb', line 59

def arrow(image, x1, y1, x2, y2, color = "#ffffff", width: 1, head: 8)
  x1, y1, x2, y2 = [x1, y1, x2, y2].map { |value| Integer(value) }
  width = Integer(width)
  head = Float(head)
  raise ArgumentError, "arrow width must be positive" unless width.positive?
  raise ArgumentError, "arrow head length must be non-negative and finite" unless head.finite? && head >= 0

  output = image.dup
  draw_line(output, x1, y1, x2, y2, color, width)
  distance = Math.hypot(x2 - x1, y2 - y1)
  if head.positive? && distance.positive?
    angle = Math.atan2(y2 - y1, x2 - x1)
    [angle + (0.65 * Math::PI), angle - (0.65 * Math::PI)].each do |side|
      draw_line(output, x2, y2, (x2 + (Math.cos(side) * head)).round, (y2 + (Math.sin(side) * head)).round, color, width)
    end
  end
  output
end

.blur(image, sigma) ⇒ Object

Parameters:

  • image (Object)
  • sigma (Numeric)

Returns:

  • (Object)


7
8
9
# File 'lib/retouch/operations/filter.rb', line 7

def blur(image, sigma)
  Kernels::Convolve.gaussian(image, sigma)
end

.border(image, width, color = "#000000") ⇒ Object

Raises:

  • (ArgumentError)


117
118
119
120
121
122
# File 'lib/retouch/operations.rb', line 117

def border(image, width, color = "#000000")
  width = Integer(width)
  raise ArgumentError, "border width must be positive" unless width.positive?

  pad(image, width, color:)
end

.brightness ⇒ Object

Parameters:

  • image (Object)
  • amount (Numeric)

Returns:

  • (Object)


27
# File 'sig/retouch.rbs', line 27

def self.brightness: (untyped image, Numeric amount) -> untyped

.contrast ⇒ Object

Parameters:

  • image (Object)
  • factor (Numeric)

Returns:

  • (Object)


28
# File 'sig/retouch.rbs', line 28

def self.contrast: (untyped image, Numeric factor) -> untyped

.crop(image, geometry, gravity: :north_west) ⇒ Object



48
49
50
51
52
53
54
55
56
57
# File 'lib/retouch/operations.rb', line 48

def crop(image, geometry, gravity: :north_west)
  geometry = Geometry.parse(geometry) unless geometry.is_a?(Geometry)
  width, height = geometry.crop_size(image.width, image.height)
  x, y = if geometry.offset_x.zero? && geometry.offset_y.zero?
           Gravity.offset(image.width, image.height, width, height, gravity)
         else
           [geometry.offset_x, geometry.offset_y]
         end
  (image.crop(x, y, width, height), image)
end

.diff(expected, actual, threshold: 0, color: "#ff00ff") ⇒ Hash[Symbol, untyped]

Parameters:

  • expected (Object)
  • actual (Object)
  • threshold: (Numeric) (defaults to: 0)
  • color: (String) (defaults to: "#ff00ff")

Returns:

  • (Hash[Symbol, untyped])

Raises:

  • (TypeError)


109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/retouch/operations/multiple.rb', line 109

def diff(expected, actual, threshold: 0, color: "#ff00ff")
  raise TypeError, "expected and actual must be Tessel::Image values" unless expected.is_a?(Tessel::Image) && actual.is_a?(Tessel::Image)
  raise ArgumentError, "images must have the same dimensions" unless expected.width == actual.width && expected.height == actual.height

  threshold = Float(threshold)
  raise ArgumentError, "threshold must be between 0 and 255" unless threshold.finite? && threshold.between?(0, 255)

  marker = Tessel::Color.pack(color).bytes
  output = Tessel::Image.new(expected.width, expected.height)
  different = 0
  expected.height.times do |y|
    expected.width.times do |x|
      left = expected[x, y]
      right = actual[x, y]
      if left.zip(right).map { |a, b| (a - b).abs }.max > threshold
        output[x, y] = marker
        different += 1
      end
    end
  end
  { image: output, pixels: different, total: expected.width * expected.height,
    rate: expected.width.zero? || expected.height.zero? ? 0.0 : different.to_f / (expected.width * expected.height) }
end

.extend(image, width, height, color: [0, 0, 0, 0], gravity: :center) ⇒ Object

Raises:

  • (ArgumentError)


107
108
109
110
111
112
113
114
115
# File 'lib/retouch/operations.rb', line 107

def extend(image, width, height, color: [0, 0, 0, 0], gravity: :center)
  width = Integer(width)
  height = Integer(height)
  raise ArgumentError, "canvas dimensions must be positive" unless width.positive? && height.positive?
  raise ArgumentError, "canvas cannot be smaller than image" if width < image.width || height < image.height

  x, y = Gravity.offset(width, height, image.width, image.height, gravity)
  Tessel::Image.new(width, height, fill: color, metadata: image.).tap { |canvas| canvas.blit(image, x, y) }
end

.flip(image) ⇒ Object



59
# File 'lib/retouch/operations.rb', line 59

def flip(image) = image.flip_vertical

.flop(image) ⇒ Object



61
62
63
64
65
66
67
# File 'lib/retouch/operations.rb', line 61

def flop(image)
  output = Tessel::Image.new(image.width, image.height, metadata: image.)
  image.height.times do |y|
    image.width.times { |x| output[image.width - x - 1, y] = image[x, y] }
  end
  output
end

.gamma ⇒ Object

Parameters:

  • image (Object)
  • value (Numeric)

Returns:

  • (Object)


29
# File 'sig/retouch.rbs', line 29

def self.gamma: (untyped image, Numeric value) -> untyped

.grayscale ⇒ Object

Parameters:

  • image (Object)

Returns:

  • (Object)


25
# File 'sig/retouch.rbs', line 25

def self.grayscale: (untyped image) -> untyped

.info(image) ⇒ Hash[Symbol, untyped]

Parameters:

  • image (Object)

Returns:

  • (Hash[Symbol, untyped])


18
19
20
21
22
23
24
25
26
27
# File 'lib/retouch/operations.rb', line 18

def info(image)
  colors = {}
  alpha = false
  image.bytes.bytes.each_slice(4) do |r, g, b, a|
    colors[[r, g, b, a]] = true
    alpha ||= a < 255
  end
  { width: image.width, height: image.height, format: image..fetch("format", "RGBA"), colors: colors.length,
    alpha: alpha, metadata: image..dup }
end

.invert ⇒ Object

Parameters:

  • image (Object)

Returns:

  • (Object)


26
# File 'sig/retouch.rbs', line 26

def self.invert: (untyped image) -> untyped

.montage(images, columns: nil, cols: nil, gap: 0, background: [0, 0, 0, 0], labels: nil) ⇒ Object

Parameters:

  • images (Array[untyped])
  • columns: (Integer, nil) (defaults to: nil)
  • cols: (Integer, nil) (defaults to: nil)
  • gap: (Integer) (defaults to: 0)
  • background: (Object) (defaults to: [0, 0, 0, 0])
  • labels: (bool, Array[String], nil) (defaults to: nil)

Returns:

  • (Object)

Raises:

  • (ArgumentError)


31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/retouch/operations/multiple.rb', line 31

def montage(images, columns: nil, cols: nil, gap: 0, background: [0, 0, 0, 0], labels: nil)
  images = validate_images(images)
  columns = Integer(columns || cols || Math.sqrt(images.length).ceil)
  gap = Integer(gap)
  raise ArgumentError, "columns must be positive" unless columns.positive?
  raise ArgumentError, "gap must not be negative" if gap.negative?

  column_widths = Array.new([images.length, columns].min, 0)
  row_heights = Array.new((images.length.to_f / columns).ceil, 0)
  images.each_with_index do |image, index|
    column = index % columns
    row = index / columns
    column_widths[column] = [column_widths[column], image.width].max
    row_heights[row] = [row_heights[row], image.height + (labels ? 15 : 0)].max
  end
  output = Tessel::Image.new(column_widths.sum + (gap * (column_widths.length - 1)), row_heights.sum + (gap * (row_heights.length - 1)), fill: background)
  row_y = 0
  row_heights.each_with_index do |row_height, row|
    column_x = 0
    column_widths.each_with_index do |column_width, column|
      index = (row * columns) + column
      break unless images[index]

      image = images[index]
      image_y = row_y + ((row_height - image.height - (labels ? 15 : 0)) / 2)
      output.blit(image, column_x + ((column_width - image.width) / 2), image_y)
      if labels
        label = labels == true ? index.to_s : String(labels.fetch(index))
        output.fill_rect(column_x, row_y + row_height - 15, column_width, 15, [0, 0, 0, 192], blend: :alpha)
        output = text(output, label, x: column_x + 3, y: row_y + row_height - 12, size: 9, color: "#ffffff")
      end
      column_x += column_width + gap
    end
    row_y += row_height + gap
  end
  output
end

.opacity ⇒ Object

Parameters:

  • image (Object)
  • amount (Numeric)

Returns:

  • (Object)


32
# File 'sig/retouch.rbs', line 32

def self.opacity: (untyped image, Numeric amount) -> untyped

.open_gif(path, frame: :all, max_pixels: 16_384 * 16_384, max_frames: 10_000, max_total_pixels: 100_000_000) ⇒ Object

Parameters:

  • path (String)
  • frame: (Symbol, Integer) (defaults to: :all)
  • max_pixels: (Integer) (defaults to: 16_384 * 16_384)
  • max_frames: (Integer) (defaults to: 10_000)
  • max_total_pixels: (Integer) (defaults to: 100_000_000)

Returns:

  • (Object)


138
139
140
141
142
143
144
# File 'lib/retouch/operations/multiple.rb', line 138

def open_gif(path, frame: :all, max_pixels: 16_384 * 16_384, max_frames: 10_000, max_total_pixels: 100_000_000)
  require_flipbook
  frames = Flipbook.read(path, max_pixels:, max_frames:, max_total_pixels:)
  return frames if frame == :all

  frames.fetch(frame == :first ? 0 : Integer(frame))
end

.overlay(image, source, x: nil, y: nil, gravity: :center, opacity: 1.0, mode: :normal) ⇒ Object

Parameters:

  • image (Object)
  • source (Object)
  • x: (Integer, nil) (defaults to: nil)
  • y: (Integer, nil) (defaults to: nil)
  • gravity: (Symbol, String) (defaults to: :center)
  • opacity: (Numeric) (defaults to: 1.0)
  • mode: (Symbol) (defaults to: :normal)

Returns:

  • (Object)

Raises:

  • (ArgumentError)


9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/retouch/operations/draw.rb', line 9

def overlay(image, source, x: nil, y: nil, gravity: :center, opacity: 1.0, mode: :normal)
  source = ImageIO.read(source) unless source.is_a?(Tessel::Image)
  opacity = Float(opacity)
  mode = mode.to_sym
  raise ArgumentError, "opacity must be between 0 and 1" unless opacity.finite? && opacity.between?(0, 1)
  raise ArgumentError, "unknown blend mode: #{mode}" unless BLENDS.include?(mode)

  offset_x, offset_y = Gravity.offset(image.width, image.height, source.width, source.height, gravity)
  offset_x = Integer(x) unless x.nil?
  offset_y = Integer(y) unless y.nil?
  output = image.dup
  source.height.times do |source_y|
    source.width.times do |source_x|
      target_x = source_x + offset_x
      target_y = source_y + offset_y
      next unless target_x.between?(0, image.width - 1) && target_y.between?(0, image.height - 1)

      source_pixel = source[source_x, source_y]
      source_pixel[3] = (source_pixel[3] * opacity).round
      next if source_pixel[3].zero?

      output[target_x, target_y] = blend_pixel(output[target_x, target_y], source_pixel, mode)
    end
  end
  output
end

.pad(image, amount, color: [0, 0, 0, 0]) ⇒ Object

Raises:

  • (ArgumentError)


100
101
102
103
104
105
# File 'lib/retouch/operations.rb', line 100

def pad(image, amount, color: [0, 0, 0, 0])
  amount = Integer(amount)
  raise ArgumentError, "padding must not be negative" if amount.negative?

  extend(image, image.width + (2 * amount), image.height + (2 * amount), color:, gravity: :center)
end

.pixelate(image, size) ⇒ Object

Parameters:

  • image (Object)
  • size (Integer)

Returns:

  • (Object)

Raises:

  • (ArgumentError)


34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/retouch/operations/filter.rb', line 34

def pixelate(image, size)
  size = Integer(size)
  raise ArgumentError, "pixel size must be positive" unless size.positive?
  return image.dup if image.width.zero? || image.height.zero?

  output = image.dup
  (0...image.height).step(size) do |y|
    height = [size, image.height - y].min
    (0...image.width).step(size) do |x|
      width = [size, image.width - x].min
      alpha_sum = red = green = blue = 0.0
      (y...(y + height)).each do |py|
        (x...(x + width)).each do |px|
          color = image[px, py]
          alpha = color[3] / 255.0
          red += color[0] * alpha
          green += color[1] * alpha
          blue += color[2] * alpha
          alpha_sum += color[3]
        end
      end
      count = width * height
      alpha = (alpha_sum / count).round
      color = alpha_sum.positive? ? [(red * 255 / alpha_sum).round, (green * 255 / alpha_sum).round, (blue * 255 / alpha_sum).round, alpha] : [0, 0, 0, 0]
      output.fill_rect(x, y, width, height, color, blend: :copy)
    end
  end
  output
end

.quantize(image, colors: 256, dither: :none, palette: nil) ⇒ Object

Parameters:

  • image (Object)
  • colors: (Integer) (defaults to: 256)
  • dither: (Symbol) (defaults to: :none)
  • palette: (Array[Array[Integer]], nil) (defaults to: nil)

Returns:

  • (Object)


17
18
19
# File 'lib/retouch/operations/color.rb', line 17

def quantize(image, colors: 256, dither: :none, palette: nil)
  Kernels.quantize(image, colors:, dither:, palette:)
end

.rect(image, x, y, width, height, color = "#ffffff", fill: false) ⇒ Object

Parameters:

  • image (Object)
  • x (Integer)
  • y (Integer)
  • width (Integer)
  • height (Integer)
  • color (String) (defaults to: "#ffffff")
  • fill: (Boolean) (defaults to: false)

Returns:

  • (Object)

Raises:

  • (ArgumentError)


40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/retouch/operations/draw.rb', line 40

def rect(image, x, y, width, height, color = "#ffffff", fill: false)
  x = Integer(x)
  y = Integer(y)
  width = Integer(width)
  height = Integer(height)
  raise ArgumentError, "rectangle dimensions must be positive" unless width.positive? && height.positive?

  output = image.dup
  if fill
    output.fill_rect(x, y, width, height, color, blend: :alpha)
  else
    output.fill_rect(x, y, width, 1, color, blend: :alpha)
    output.fill_rect(x, y + height - 1, width, 1, color, blend: :alpha)
    output.fill_rect(x, y, 1, height, color, blend: :alpha)
    output.fill_rect(x + width - 1, y, 1, height, color, blend: :alpha)
  end
  output
end

.resize(image, geometry, filter: :lanczos3, gravity: :center) ⇒ Object



29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/retouch/operations.rb', line 29

def resize(image, geometry, filter: :lanczos3, gravity: :center)
  geometry = Geometry.parse(geometry) unless geometry.is_a?(Geometry)
  width, height = geometry.target_size(image.width, image.height)
  output = resample(image, width, height, filter)
  if geometry.mode == :cover && geometry.width && geometry.height
    crop_width, crop_height = geometry.crop_size(image.width, image.height)
    x, y = Gravity.offset(output.width, output.height, crop_width, crop_height, gravity)
    x = geometry.offset_x unless geometry.offset_x.zero?
    y = geometry.offset_y unless geometry.offset_y.zero?
    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)
  end
  output
end

.rotate(image, degrees, background: [0, 0, 0, 0]) ⇒ Object

Raises:

  • (ArgumentError)


69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/retouch/operations.rb', line 69

def rotate(image, degrees, background: [0, 0, 0, 0])
  angle = Float(degrees) % 360
  raise ArgumentError, "degrees must be finite" unless angle.finite?

  return image.dup if angle.zero?
  return rotate_quarter(image, 1) if angle == 90
  return rotate_quarter(image, 2) if angle == 180
  return rotate_quarter(image, 3) if angle == 270

  radians = angle * Math::PI / 180
  cosine = Math.cos(radians)
  sine = Math.sin(radians)
  width = ((image.width * cosine.abs) + (image.height * sine.abs)).ceil
  height = ((image.width * sine.abs) + (image.height * cosine.abs)).ceil
  output = Tessel::Image.new(width, height, fill: background, metadata: image.)
  source_cx = (image.width - 1) / 2.0
  source_cy = (image.height - 1) / 2.0
  target_cx = (width - 1) / 2.0
  target_cy = (height - 1) / 2.0
  height.times do |y|
    width.times do |x|
      dx = x - target_cx
      dy = y - target_cy
      sx = (cosine * dx) + (sine * dy) + source_cx
      sy = (-sine * dx) + (cosine * dy) + source_cy
      output[x, y] = sample_bilinear(image, sx, sy, background) if sx.between?(0, image.width - 1) && sy.between?(0, image.height - 1)
    end
  end
  output
end

.saturate ⇒ Object

Parameters:

  • image (Object)
  • factor (Numeric)

Returns:

  • (Object)


30
# File 'sig/retouch.rbs', line 30

def self.saturate: (untyped image, Numeric factor) -> untyped

.sharpen(image, sigma, amount: 1.0) ⇒ Object

Parameters:

  • image (Object)
  • sigma (Numeric)
  • amount: (Numeric) (defaults to: 1.0)

Returns:

  • (Object)

Raises:

  • (ArgumentError)


11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'lib/retouch/operations/filter.rb', line 11

def sharpen(image, sigma, amount: 1.0)
  amount = Float(amount)
  raise ArgumentError, "sharpen amount must be between 0 and 10" unless amount.finite? && amount.between?(0, 10)

  blurred = blur(image, sigma)
  source = image.bytes
  smooth = blurred.bytes
  output = source.dup
  (0...(image.width * image.height)).each do |index|
    offset = index * 4
    original_alpha = source.getbyte(offset + 3) / 255.0
    smooth_alpha = smooth.getbyte(offset + 3) / 255.0
    3.times do |channel|
      original = source.getbyte(offset + channel) * original_alpha
      blurred_color = smooth.getbyte(offset + channel) * smooth_alpha
      sharpened = original + (amount * (original - blurred_color))
      color = original_alpha.positive? ? (sharpened / original_alpha).round.clamp(0, 255) : 0
      output.setbyte(offset + channel, color)
    end
  end
  Tessel::Image.from_rgba(image.width, image.height, output, metadata: image.)
end

.spritesheet(images, max_width: 2048, gap: 0, background: [0, 0, 0, 0]) ⇒ [untyped, Array[Hash[Symbol, Integer]]]

Parameters:

  • images (Array[untyped])
  • max_width: (Integer) (defaults to: 2048)
  • gap: (Integer) (defaults to: 0)
  • background: (Object) (defaults to: [0, 0, 0, 0])

Returns:

  • ([untyped, Array[Hash[Symbol, Integer]]])

Raises:

  • (ArgumentError)


69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/retouch/operations/multiple.rb', line 69

def spritesheet(images, max_width: 2048, gap: 0, background: [0, 0, 0, 0])
  images = validate_images(images)
  max_width = Integer(max_width)
  gap = Integer(gap)
  raise ArgumentError, "max_width must be positive" unless max_width.positive?
  raise ArgumentError, "gap must not be negative" if gap.negative?
  raise ArgumentError, "an image is wider than max_width" if images.any? { |image| image.width > max_width }

  bound_height = images.sum(&:height) + (gap * images.length)
  free = [{ x: 0, y: 0, width: max_width, height: bound_height }]
  placements = Array.new(images.length)
  images.each_with_index.sort_by { |image, index| [-image.width * image.height, -[image.width, image.height].max, index] }.each do |image, index|
    packed_width = image.width + gap
    packed_height = image.height + gap
    position = free.select { |rect| packed_width <= rect[:width] && packed_height <= rect[:height] }
                   .map do |rect|
      leftover_width = rect[:width] - packed_width
      leftover_height = rect[:height] - packed_height
      score = [
        [leftover_width, leftover_height].min,
        rect[:y],
        rect[:x],
        [leftover_width, leftover_height].max
      ]
      [score, rect[:x], rect[:y]]
    end.min_by(&:first)
    raise ArgumentError, "could not pack images within max_width" unless position

    _score, x, y = position
    packed = { x:, y:, width: packed_width, height: packed_height }
    placements[index] = { index:, x:, y:, width: image.width, height: image.height }
    free = split_free_rectangles(free, packed)
  end
  used_width = placements.map { |item| item[:x] + item[:width] }.max || 0
  height = placements.map { |item| item[:y] + item[:height] }.max || 0
  output = Tessel::Image.new(used_width, height, fill: background)
  images.zip(placements).each { |image, item| output.blit(image, item[:x], item[:y]) }
  [output, placements]
end

.text(image, string, x: 0, y: 0, at: nil, size: 16, font: nil, color: "#ffffff", background: nil, padding: 4) ⇒ Object

Parameters:

  • image (Object)
  • text (String)
  • options (Object)

Returns:

  • (Object)

Raises:

  • (ArgumentError)


78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/retouch/operations/draw.rb', line 78

def text(image, string, x: 0, y: 0, at: nil, size: 16, font: nil, color: "#ffffff", background: nil, padding: 4)
  begin
    require "glyphic"
  rescue LoadError => e
    raise Error, "text requires the glyphic gem (gem install glyphic)", cause: e
  end
  size = Integer(size)
  raise ArgumentError, "font size must be positive" unless size.positive?

  font_object = font ? Glyphic.load(font, size: Integer(size)) : Glyphic.default
  rendered = font_object.render(String(string), color: Tessel::Color.pack(color).bytes)
  if !font && size != font_object.line_height
    scale = size.to_f / font_object.line_height
    rendered = rendered.scale_nearest([(rendered.width * scale).round, 1].max, [(rendered.height * scale).round, 1].max)
  end
  text_width = rendered.width
  text_height = rendered.height
  padding = Integer(padding)
  raise ArgumentError, "text padding must not be negative" if padding.negative?

  if at
    x, y = Gravity.offset(image.width, image.height, text_width + (padding * 2), text_height + (padding * 2), at)
    x += padding
    y += padding
  else
    x = Integer(x)
    y = Integer(y)
  end
  output = image.dup
  output.fill_rect(x - padding, y - padding, text_width + (padding * 2), text_height + (padding * 2), background, blend: :alpha) if background
  output.blit(rendered, x, y)
  output
end

.thumbnail(image, geometry, **options) ⇒ Object



43
44
45
46
# File 'lib/retouch/operations.rb', line 43

def thumbnail(image, geometry, **options)
  result = resize(image, geometry, **options)
  Tessel::Image.from_rgba(result.width, result.height, result.bytes)
end

.tint(image, color, amount: 1.0) ⇒ Object

Parameters:

  • image (Object)
  • color (String)
  • amount: (Numeric) (defaults to: 1.0)

Returns:

  • (Object)


13
14
15
# File 'lib/retouch/operations/color.rb', line 13

def tint(image, color, amount: 1.0)
  Kernels::LUT.apply(image, :tint, color, amount: Float(amount))
end

.trim(image, color: nil, fuzz: 0) ⇒ Object

Raises:

  • (ArgumentError)


124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/retouch/operations.rb', line 124

def trim(image, color: nil, fuzz: 0)
  fuzz = Integer(fuzz)
  raise ArgumentError, "fuzz must be between 0 and 255" unless fuzz.between?(0, 255)

  target = Tessel::Color.pack(color || image[0, 0] || [0, 0, 0, 0]).bytes
  bounds = [image.width, image.height, -1, -1]
  image.height.times do |y|
    image.width.times do |x|
      pixel = image[x, y]
      next if pixel.zip(target).all? { |a, b| (a - b).abs <= fuzz }

      bounds[0] = [bounds[0], x].min
      bounds[1] = [bounds[1], y].min
      bounds[2] = [bounds[2], x].max
      bounds[3] = [bounds[3], y].max
    end
  end
  return image.dup if bounds[2].negative?

  (image.crop(bounds[0], bounds[1], bounds[2] - bounds[0] + 1, bounds[3] - bounds[1] + 1), image)
end

.watermark(image, source, **options) ⇒ Object

Parameters:

  • image (Object)
  • source (Object)
  • options (Object)

Returns:

  • (Object)


36
37
38
# File 'lib/retouch/operations/draw.rb', line 36

def watermark(image, source, **options)
  overlay(image, source, **options)
end