Class: Everywhere::Raster

Inherits:
Object
  • Object
show all
Defined in:
lib/everywhere/raster.rb

Overview

A mutable RGBA raster and the handful of pixel operations the icon generator needs — decode/encode PNG, high-quality resampling, pasting, and a superellipse ("squircle") alpha mask. Pure Ruby (zlib only): no ImageMagick, no sips, no Tauri. Works the same on any OS the CLI runs on.

Pixels are stored as a flat 8-bit RGBA byte string (row-major, 4 bytes per pixel). Resampling is a separable area/box filter done in premultiplied alpha so transparent regions never bleed their colour into opaque edges.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(width, height, pixels = nil) ⇒ Raster

Returns a new instance of Raster.



17
18
19
20
21
# File 'lib/everywhere/raster.rb', line 17

def initialize(width, height, pixels = nil)
  @width = width
  @height = height
  @pixels = pixels || ("\x00".b * (width * height * 4))
end

Instance Attribute Details

#heightObject (readonly)

Returns the value of attribute height.



15
16
17
# File 'lib/everywhere/raster.rb', line 15

def height
  @height
end

#pixelsObject (readonly)

Returns the value of attribute pixels.



15
16
17
# File 'lib/everywhere/raster.rb', line 15

def pixels
  @pixels
end

#widthObject (readonly)

Returns the value of attribute width.



15
16
17
# File 'lib/everywhere/raster.rb', line 15

def width
  @width
end

Class Method Details

.axis_weights(n_in, n_out) ⇒ Object

For each output index along an axis of n_out pixels, the list of [input_index, weight] contributions, where weights sum to 1 (area filter: each output pixel is the average of the input pixels it overlaps).



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/everywhere/raster.rb', line 130

def self.axis_weights(n_in, n_out)
  scale = n_in.to_f / n_out
  Array.new(n_out) do |i|
    start = i * scale
    finish = [start + scale, n_in.to_f].min # float slop can push this past n_in
    weights = []
    k = [start.floor, 0].max
    while k < finish && k < n_in
      lo = [k.to_f, start].max
      hi = [k + 1.0, finish].min
      overlap = hi - lo
      weights << [k, overlap / scale] if overlap > 0
      k += 1
    end
    weights
  end
end

.decode_png(path) ⇒ Object

Decode a PNG file into a Raster, or nil if it's a flavor PNG.decode_rgba can't read (palette, 16-bit, interlaced, grayscale).



30
31
32
33
34
35
# File 'lib/everywhere/raster.rb', line 30

def self.decode_png(path)
  width, height, bytes = PNG.decode_rgba(path)
  return nil unless width

  new(width, height, bytes)
end

.transparent(width, height) ⇒ Object

A fully transparent canvas.



24
25
26
# File 'lib/everywhere/raster.rb', line 24

def self.transparent(width, height)
  new(width, height)
end

Instance Method Details

#encode_pngObject



37
38
39
# File 'lib/everywhere/raster.rb', line 37

def encode_png
  PNG.encode_rgba(width, height, pixels)
end

#mask_region!(x0, y0, x1, y1) ⇒ Object

Multiply the alpha channel of every pixel in [x0, x1) x [y0, y1) by the coverage the block returns for pixel (x, y) — a value in 0.0..1.0. Pixels outside the range keep their (already transparent) alpha.



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/everywhere/raster.rb', line 110

def mask_region!(x0, y0, x1, y1)
  y0.upto(y1 - 1) do |y|
    base = y * width
    x0.upto(x1 - 1) do |x|
      cov = yield(x, y)
      next if cov >= 1.0

      idx = (base + x) * 4 + 3
      a = pixels.getbyte(idx)
      next if a.zero?

      pixels.setbyte(idx, cov <= 0.0 ? 0 : (a * cov).round)
    end
  end
  self
end

#paste!(src, ox, oy) ⇒ Object

Copy src into this raster with its top-left at (ox, oy), replacing pixels (the canvas is transparent underneath, so a plain copy is the composite).



98
99
100
101
102
103
104
105
# File 'lib/everywhere/raster.rb', line 98

def paste!(src, ox, oy)
  row = src.width * 4
  src.height.times do |y|
    dst_index = ((oy + y) * width + ox) * 4
    pixels[dst_index, row] = src.pixels.byteslice(y * row, row)
  end
  self
end

#resample(new_width, new_height) ⇒ Object

Resample to a new size with a separable area filter. Great for the downscales an icon set is made of; degrades gracefully on upscale.



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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
# File 'lib/everywhere/raster.rb', line 47

def resample(new_width, new_height)
  return self if new_width == width && new_height == height

  # Premultiplied float channels: pr = r * (a/255), etc.
  pr = Array.new(width * height)
  pg = Array.new(width * height)
  pb = Array.new(width * height)
  pa = Array.new(width * height)
  i = 0
  (width * height).times do |p|
    a = pixels.getbyte(i + 3)
    af = a / 255.0
    pr[p] = pixels.getbyte(i) * af
    pg[p] = pixels.getbyte(i + 1) * af
    pb[p] = pixels.getbyte(i + 2) * af
    pa[p] = a.to_f
    i += 4
  end

  # Horizontal pass (width -> new_width), then vertical (height -> new_height).
  wx = self.class.axis_weights(width, new_width)
  hr = resample_axis(pr, width, height, new_width, wx, rows: true)
  hg = resample_axis(pg, width, height, new_width, wx, rows: true)
  hb = resample_axis(pb, width, height, new_width, wx, rows: true)
  ha = resample_axis(pa, width, height, new_width, wx, rows: true)

  wy = self.class.axis_weights(height, new_height)
  vr = resample_axis(hr, new_width, height, new_height, wy, rows: false)
  vg = resample_axis(hg, new_width, height, new_height, wy, rows: false)
  vb = resample_axis(hb, new_width, height, new_height, wy, rows: false)
  va = resample_axis(ha, new_width, height, new_height, wy, rows: false)

  out = String.new(capacity: new_width * new_height * 4)
  (new_width * new_height).times do |p|
    a = va[p]
    if a <= 0.001
      out << "\x00\x00\x00\x00".b
    else
      out << [
        (vr[p] * 255.0 / a).round.clamp(0, 255),
        (vg[p] * 255.0 / a).round.clamp(0, 255),
        (vb[p] * 255.0 / a).round.clamp(0, 255),
        a.round.clamp(0, 255)
      ].pack("C4")
    end
  end
  self.class.new(new_width, new_height, out)
end

#write_png(path) ⇒ Object



41
42
43
# File 'lib/everywhere/raster.rb', line 41

def write_png(path)
  File.binwrite(path, encode_png)
end