Module: Everywhere::PNG

Defined in:
lib/everywhere/png.rb

Overview

Minimal PNG surgery: Tauri requires RGBA icons, users hand us whatever their design tool exported. Converts 8-bit RGB (color type 2) to RGBA (color type 6); passes RGBA through untouched. No image libraries needed.

Constant Summary collapse

SIGNATURE =
"\x89PNG\r\n\x1a\n".b

Class Method Summary collapse

Class Method Details

.add_alpha(raw, width, height) ⇒ Object

Unfilter RGB scanlines, emit unfiltered (filter 0) RGBA scanlines.



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

def add_alpha(raw, width, height)
  bpp = 3
  stride = width * bpp
  prev = "\0".b * stride
  out = +"".b

  height.times do |y|
    filter = raw.getbyte(y * (stride + 1))
    line = raw.byteslice(y * (stride + 1) + 1, stride).dup
    unfilter!(line, prev, filter, bpp)

    out << "\0"
    width.times do |x|
      out << line.byteslice(x * bpp, bpp) << "\xFF".b
    end
    prev = line
  end
  out
end

.chunk(type, payload) ⇒ Object



160
161
162
# File 'lib/everywhere/png.rb', line 160

def chunk(type, payload)
  [payload.bytesize].pack("N") + type + payload + [Zlib.crc32(type + payload)].pack("N")
end

.decode_rgba(path) ⇒ Object

Decode an 8-bit RGB/RGBA PNG to a flat RGBA pixel buffer. Returns [width, height, bytes] (bytes = widthheight4, no per-scanline filter byte) or nil for flavors we don't handle (palette, 16-bit, interlaced, grayscale). This is the pixel gateway the Raster/Icon generators build on.



46
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
# File 'lib/everywhere/png.rb', line 46

def decode_rgba(path)
  data = File.binread(path)
  return nil unless data.start_with?(SIGNATURE)

  chunks = read_chunks(data)
  ihdr = chunks.find { |type, _| type == "IHDR" }&.last or return nil
  width, height, depth, color, _comp, _filter, interlace = ihdr.unpack("NNC5")
  return nil unless depth == 8 && interlace.zero?
  return nil unless color == 2 || color == 6 # RGB or RGBA

  bpp = color == 6 ? 4 : 3
  raw = Zlib::Inflate.inflate(chunks.select { |t, _| t == "IDAT" }.map(&:last).join)
  stride = width * bpp
  prev = "\0".b * stride
  out = String.new(capacity: width * height * 4)

  height.times do |y|
    filter = raw.getbyte(y * (stride + 1))
    line = raw.byteslice(y * (stride + 1) + 1, stride).dup
    unfilter!(line, prev, filter, bpp)
    if bpp == 4
      out << line
    else
      x = 0
      while x < width
        out << line.byteslice(x * 3, 3) << "\xFF".b
        x += 1
      end
    end
    prev = line
  end
  [width, height, out]
end

.encode_rgba(width, height, rgba) ⇒ Object

Encode a flat RGBA pixel buffer to PNG bytes (filter 0, single IDAT).



81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/everywhere/png.rb', line 81

def encode_rgba(width, height, rgba)
  stride = width * 4
  raw = String.new(capacity: (stride + 1) * height)
  height.times do |y|
    raw << "\0".b # filter: None
    raw << rgba.byteslice(y * stride, stride)
  end

  out = SIGNATURE.dup
  out << chunk("IHDR", [width, height, 8, 6, 0, 0, 0].pack("NNC5"))
  out << chunk("IDAT", Zlib::Deflate.deflate(raw, Zlib::BEST_COMPRESSION))
  out << chunk("IEND", "")
  out
end

.ensure_rgba(src, dst) ⇒ Object

Writes an RGBA copy of src to dst. Returns true on success, false for PNG flavors we don't handle (palette, 16-bit, interlaced...).



16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/everywhere/png.rb', line 16

def ensure_rgba(src, dst)
  data = File.binread(src)
  return false unless data.start_with?(SIGNATURE)

  chunks = read_chunks(data)
  ihdr = chunks.find { |type, _| type == "IHDR" }&.last or return false
  width, height, depth, color, _comp, _filter, interlace = ihdr.unpack("NNC5")
  return false unless depth == 8 && interlace.zero?

  if color == 6 # already RGBA
    FileUtils.cp(src, dst)
    return true
  end
  return false unless color == 2 # 8-bit RGB

  raw = Zlib::Inflate.inflate(chunks.select { |t, _| t == "IDAT" }.map(&:last).join)
  rgba = add_alpha(raw, width, height)

  out = SIGNATURE.dup
  out << chunk("IHDR", [width, height, 8, 6, 0, 0, 0].pack("NNC5"))
  out << chunk("IDAT", Zlib::Deflate.deflate(rgba))
  out << chunk("IEND", "")
  File.binwrite(dst, out)
  true
end

.read_chunks(data) ⇒ Object



96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/everywhere/png.rb', line 96

def read_chunks(data)
  chunks = []
  pos = 8
  while pos < data.bytesize
    length = data[pos, 4].unpack1("N")
    type = data[pos + 4, 4]
    chunks << [type, data[pos + 8, length]]
    pos += 12 + length
    break if type == "IEND"
  end
  chunks
end

.unfilter!(line, prev, filter, bpp) ⇒ Object



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/everywhere/png.rb', line 130

def unfilter!(line, prev, filter, bpp)
  stride = line.bytesize
  case filter
  when 0 # None
  when 1 # Sub
    bpp.upto(stride - 1) { |i| line.setbyte(i, (line.getbyte(i) + line.getbyte(i - bpp)) & 0xFF) }
  when 2 # Up
    0.upto(stride - 1) { |i| line.setbyte(i, (line.getbyte(i) + prev.getbyte(i)) & 0xFF) }
  when 3 # Average
    0.upto(stride - 1) do |i|
      left = i >= bpp ? line.getbyte(i - bpp) : 0
      line.setbyte(i, (line.getbyte(i) + ((left + prev.getbyte(i)) >> 1)) & 0xFF)
    end
  when 4 # Paeth
    0.upto(stride - 1) do |i|
      left = i >= bpp ? line.getbyte(i - bpp) : 0
      up = prev.getbyte(i)
      up_left = i >= bpp ? prev.getbyte(i - bpp) : 0
      p = left + up - up_left
      pa = (p - left).abs
      pb = (p - up).abs
      pc = (p - up_left).abs
      predictor = pa <= pb && pa <= pc ? left : (pb <= pc ? up : up_left)
      line.setbyte(i, (line.getbyte(i) + predictor) & 0xFF)
    end
  else
    raise Error, "unknown PNG filter #{filter}"
  end
end