tessel 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7dc055bf84ae99620aaecb65e788e52aa209b81d2b153a569dd12137d8657ca8
4
+ data.tar.gz: a060a93831f6dcb8dc139b407b0861b7fca2c2ddda84fc0ef8b80bbf6f77e7a3
5
+ SHA512:
6
+ metadata.gz: 746fed4a4d9732e31d133d3d7757c92cc568ab5205c4ebb5ac41d2616134764444911120c839c91a9adbb67366eb681f419187ba9f691882cb4f0453bd14bf28
7
+ data.tar.gz: b4c57afd371b3a95276298ab91a030823360d7e27916da56bebf28dd97ced4782f18daa3cc466eab034efba574bd633c8aea6b0af80a88ba5c943867db36473b
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-09-23
6
+
7
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ydah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # Tessel
2
+
3
+ [![Gem version](https://badge.fury.io/rb/tessel.svg)](https://rubygems.org/gems/tessel)
4
+ [![Downloads](https://img.shields.io/gem/dt/tessel?label=downloads)](https://rubygems.org/gems/tessel)
5
+ [![CI](https://github.com/rbgfx/tessel/actions/workflows/ci.yml/badge.svg)](https://github.com/rbgfx/tessel/actions/workflows/ci.yml)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D3.1-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
7
+ [![License](https://img.shields.io/badge/license-MIT-750014.svg)](LICENSE.txt)
8
+
9
+ > Dependency-light image I/O and mutable RGBA8 image surfaces for Ruby graphics.
10
+
11
+ Tessel is the shared pixel layer for the rbgfx ecosystem. It reads and writes
12
+ common image formats and gives drawing code a small, predictable RGBA8 surface.
13
+
14
+ **[Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [Development](#development)**
15
+
16
+ ## Features
17
+
18
+ - PNG decoding and encoding, including filters, Adam7 interlace, palettes, and
19
+ text metadata.
20
+ - PPM (P3/P6) and BMP (24/32-bit) input and output.
21
+ - Straight-alpha RGBA8 pixels with row-major, top-down storage.
22
+ - Clipped rectangles, horizontal spans, alpha blits, masks, crop, and resize.
23
+ - Explicit decode, unsupported-format, and pixel-limit errors.
24
+ - Optional RBGL framebuffer conversion.
25
+
26
+ ## Installation
27
+
28
+ Add Tessel to your Gemfile:
29
+
30
+ ~~~ruby
31
+ gem "tessel"
32
+ ~~~
33
+
34
+ Then run:
35
+
36
+ ~~~sh
37
+ bundle install
38
+ ~~~
39
+
40
+ Or install the released gem directly:
41
+
42
+ ~~~sh
43
+ gem install tessel
44
+ ~~~
45
+
46
+ ## Quick start
47
+
48
+ ~~~ruby
49
+ require "tessel"
50
+
51
+ image = Tessel::Image.new(320, 180, fill: "#101827")
52
+ image.fill_rect(20, 20, 80, 40, "#e85d75")
53
+ image.write("out.png", filter: :adaptive)
54
+
55
+ copy = Tessel.read("out.png")
56
+ puts [copy.width, copy.height, copy[20, 20]].inspect
57
+ ~~~
58
+
59
+ Use <code>fill_rect</code>, <code>hspan</code>, <code>blit</code>, and
60
+ <code>blit_mask</code> for drawing. Image operations clip to the surface
61
+ boundaries.
62
+
63
+ ## Development
64
+
65
+ ~~~sh
66
+ bundle install
67
+ bundle exec rake verify
68
+ ~~~
69
+
70
+ Performance scripts are available for local, workload-specific checks:
71
+
72
+ ~~~sh
73
+ ruby bench/decode_bench.rb
74
+ ruby bench/encode_bench.rb
75
+ ruby bench/raster_bench.rb
76
+ ~~~
77
+
78
+ ## License
79
+
80
+ [MIT](LICENSE.txt)
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
9
+ task verify: :spec
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark"
4
+ require_relative "../lib/tessel"
5
+
6
+ image = Tessel::Image.new(1024, 1024)
7
+ image.fill_rect(0, 0, image.width, image.height, [40, 80, 120, 255])
8
+ encoded = Tessel::PNG.encode(image, filter: :up)
9
+
10
+ puts "decode 1024x1024 RGBA8"
11
+ Benchmark.bm(8) { |benchmark| benchmark.report { Tessel.decode(encoded) } }
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark"
4
+ require_relative "../lib/tessel"
5
+
6
+ image = Tessel::Image.new(1024, 1024)
7
+ image.fill_rect(0, 0, image.width, image.height, [40, 80, 120, 255])
8
+
9
+ puts "encode 1024x1024 RGBA8"
10
+ Benchmark.bm(8) { |benchmark| benchmark.report { Tessel::PNG.encode(image, filter: :up) } }
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark"
4
+ require_relative "../lib/tessel"
5
+
6
+ puts "raster 640x480"
7
+ Benchmark.bm(12) do |benchmark|
8
+ benchmark.report("clear") do
9
+ image = Tessel::Image.new(640, 480)
10
+ image.clear([0, 0, 0, 255])
11
+ end
12
+ benchmark.report("1000 rectangles") do
13
+ image = Tessel::Image.new(640, 480)
14
+ 1000.times { |index| image.fill_rect(index % 620, index % 460, 20, 20, [255, 80, 40, 255]) }
15
+ end
16
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tessel"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tessel"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../tessel"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../tessel"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../tessel"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../tessel"
data/lib/tessel/png.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tessel"
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "rbgl"
5
+ rescue LoadError => error
6
+ raise LoadError, "tessel/rbgl requires the rbgl gem: #{error.message}"
7
+ end
8
+
9
+ module Tessel
10
+ module RBGL
11
+ module_function
12
+
13
+ def image_to_texture(image)
14
+ colors = image.bytes.bytes.each_slice(4).map { |r, g, b, a| Larb::Color.rgba(r / 255.0, g / 255.0, b / 255.0, a / 255.0) }
15
+ ::RBGL::Engine::Texture.new(image.width, image.height, colors)
16
+ end
17
+
18
+ def texture_from_png(path)
19
+ image_to_texture(Tessel.read(path))
20
+ end
21
+
22
+ def framebuffer_to_image(framebuffer)
23
+ Image.from_rgba(framebuffer.width, framebuffer.height, framebuffer.to_rgba_bytes)
24
+ end
25
+ end
26
+ end
27
+
28
+ class RBGL::Engine::Texture
29
+ def self.from_image(image)
30
+ Tessel::RBGL.image_to_texture(image)
31
+ end
32
+
33
+ def self.from_png(path)
34
+ Tessel::RBGL.texture_from_png(path)
35
+ end
36
+ end
37
+
38
+ class RBGL::Engine::Framebuffer
39
+ def to_image
40
+ Tessel::RBGL.framebuffer_to_image(self)
41
+ end
42
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessel
4
+ VERSION = "0.1.0"
5
+ end
data/lib/tessel.rb ADDED
@@ -0,0 +1,763 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ require_relative "tessel/version"
6
+
7
+ module Tessel
8
+ SIGNATURE = "\x89PNG\r\n\x1a\n".b.freeze
9
+
10
+ class Error < StandardError; end
11
+ class DecodeError < ArgumentError
12
+ attr_reader :offset, :chunk
13
+
14
+ def initialize(message, offset: nil, chunk: nil)
15
+ @offset = offset
16
+ @chunk = chunk
17
+ super(message)
18
+ end
19
+ end
20
+ class UnsupportedError < ArgumentError; end
21
+ class LimitError < ArgumentError; end
22
+
23
+ module Color
24
+ module_function
25
+
26
+ def pack(value)
27
+ bytes = case value
28
+ when String
29
+ if value.bytesize == 4 && !value.start_with?("#")
30
+ value.bytes
31
+ else
32
+ hex = value.delete_prefix("#")
33
+ raise ArgumentError, "color must be #rrggbb or #rrggbbaa" unless [6, 8].include?(hex.length) && hex.match?(/\A[0-9a-fA-F]+\z/)
34
+
35
+ hex.scan(/../).map { |part| part.to_i(16) }
36
+ end
37
+ when Array
38
+ raise TypeError, "color must contain 3 or 4 channels" unless [3, 4].include?(value.length)
39
+ raise TypeError, "color channels must be integers" unless value.all? { |channel| channel.is_a?(Integer) }
40
+ raise ArgumentError, "color channels must be between 0 and 255" unless value.all? { |channel| (0..255).cover?(channel) }
41
+
42
+ value
43
+ else
44
+ if value.respond_to?(:to_bytes)
45
+ Array(value.to_bytes)
46
+ else
47
+ raise TypeError, "unsupported color: #{value.class}"
48
+ end
49
+ end
50
+ bytes = bytes.dup
51
+ bytes << 255 if bytes.length == 3
52
+ raise TypeError, "color must contain 3 or 4 channels" unless bytes.length == 4
53
+ raise ArgumentError, "color channels must be between 0 and 255" unless bytes.all? { |channel| channel.is_a?(Integer) && (0..255).cover?(channel) }
54
+
55
+ bytes.pack("C*").b.freeze
56
+ end
57
+ end
58
+
59
+ class Image
60
+ attr_reader :width, :height, :metadata
61
+
62
+ def initialize(width, height, fill: [0, 0, 0, 0], metadata: {})
63
+ @width = Integer(width)
64
+ @height = Integer(height)
65
+ raise ArgumentError, "width and height must be non-negative" if @width.negative? || @height.negative?
66
+
67
+ @data = Color.pack(fill) * (@width * @height)
68
+ @metadata = metadata.transform_keys(&:to_s).transform_values(&:to_s)
69
+ end
70
+
71
+ def self.from_rgba(width, height, bytes, metadata: {})
72
+ width = Integer(width)
73
+ height = Integer(height)
74
+ raise ArgumentError, "width and height must be non-negative" if width.negative? || height.negative?
75
+ bytes = String(bytes).b
76
+ expected = width * height * 4
77
+ raise ArgumentError, "RGBA byte length must be #{expected}" unless bytes.bytesize == expected
78
+
79
+ image = allocate
80
+ image.instance_variable_set(:@width, width)
81
+ image.instance_variable_set(:@height, height)
82
+ image.instance_variable_set(:@data, bytes.dup)
83
+ image.instance_variable_set(:@metadata, metadata.transform_keys(&:to_s).transform_values(&:to_s))
84
+ image
85
+ end
86
+
87
+ def bytes
88
+ @data.dup.freeze
89
+ end
90
+
91
+ alias to_rgba_bytes bytes
92
+
93
+ def [](x, y)
94
+ x = Integer(x)
95
+ y = Integer(y)
96
+ return nil unless x.between?(0, @width - 1) && y.between?(0, @height - 1)
97
+
98
+ @data.byteslice((y * @width + x) * 4, 4).unpack("C4")
99
+ end
100
+
101
+ def []=(x, y, color)
102
+ raise FrozenError, "can't modify frozen image" if frozen?
103
+
104
+ x = Integer(x)
105
+ y = Integer(y)
106
+ return color unless x.between?(0, @width - 1) && y.between?(0, @height - 1)
107
+
108
+ @data[((y * @width + x) * 4), 4] = Color.pack(color)
109
+ color
110
+ end
111
+
112
+ def clear(color)
113
+ raise FrozenError, "can't modify frozen image" if frozen?
114
+
115
+ @data = Color.pack(color) * (@width * @height)
116
+ self
117
+ end
118
+
119
+ def hspan(x0, x1, y, color, blend: :copy)
120
+ fill_rect(x0, y, Integer(x1) - Integer(x0) + 1, 1, color, blend: blend)
121
+ end
122
+
123
+ def fill_rect(x, y, width, height, color, blend: :copy)
124
+ raise FrozenError, "can't modify frozen image" if frozen?
125
+ raise ArgumentError, "unknown blend: #{blend}" unless %i[copy alpha].include?(blend)
126
+
127
+ x, y, width, height = Integer(x), Integer(y), Integer(width), Integer(height)
128
+ x0 = [x, 0].max
129
+ y0 = [y, 0].max
130
+ x1 = [x + width, @width].min
131
+ y1 = [y + height, @height].min
132
+ return self if x0 >= x1 || y0 >= y1
133
+
134
+ packed = Color.pack(color)
135
+ if blend == :copy || packed.getbyte(3) == 255
136
+ row = packed * (x1 - x0)
137
+ (y0...y1).each { |row_y| @data[((row_y * @width + x0) * 4), row.bytesize] = row }
138
+ elsif packed.getbyte(3).positive?
139
+ (y0...y1).each { |row_y| (x0...x1).each { |pixel_x| blend_pixel(pixel_x, row_y, packed) } }
140
+ end
141
+ self
142
+ end
143
+
144
+ def blit(source, dx, dy, sx: 0, sy: 0, w: nil, h: nil, blend: :alpha)
145
+ raise FrozenError, "can't modify frozen image" if frozen?
146
+ raise TypeError, "source must be a Tessel::Image" unless source.is_a?(Image)
147
+ raise ArgumentError, "unknown blend: #{blend}" unless %i[copy alpha].include?(blend)
148
+
149
+ source = source.dup if source.equal?(self)
150
+
151
+ sx, sy, dx, dy = Integer(sx), Integer(sy), Integer(dx), Integer(dy)
152
+ w = source.width - sx if w.nil?
153
+ h = source.height - sy if h.nil?
154
+ w, h = Integer(w), Integer(h)
155
+ (0...h).each do |row|
156
+ source_x = sx
157
+ target_x = dx
158
+ count = w
159
+ source_y = sy + row
160
+ target_y = dy + row
161
+ next unless source_y.between?(0, source.height - 1) && target_y.between?(0, @height - 1)
162
+
163
+ if target_x.negative?
164
+ source_x -= target_x
165
+ count += target_x
166
+ target_x = 0
167
+ end
168
+ if source_x.negative?
169
+ target_x -= source_x
170
+ count += source_x
171
+ source_x = 0
172
+ end
173
+ count = [count, @width - target_x, source.width - source_x].min
174
+ next if count <= 0
175
+
176
+ if blend == :copy
177
+ @data[((target_y * @width + target_x) * 4), count * 4] = source.instance_variable_get(:@data).byteslice((source_y * source.width + source_x) * 4, count * 4)
178
+ else
179
+ (0...count).each do |offset|
180
+ source_pixel = source.instance_variable_get(:@data).byteslice((source_y * source.width + source_x + offset) * 4, 4)
181
+ blend_pixel(target_x + offset, target_y, source_pixel)
182
+ end
183
+ end
184
+ end
185
+ self
186
+ end
187
+
188
+ def blit_mask(mask, dx, dy, color)
189
+ raise FrozenError, "can't modify frozen image" if frozen?
190
+ raise TypeError, "mask must respond to width, height and bytes" unless mask.respond_to?(:width) && mask.respond_to?(:height) && mask.respond_to?(:bytes)
191
+
192
+ packed = Color.pack(color)
193
+ mask.bytes.bytes.each_with_index do |coverage, index|
194
+ next if coverage.zero?
195
+
196
+ x = Integer(dx) + index % mask.width
197
+ y = Integer(dy) + index / mask.width
198
+ next unless x.between?(0, @width - 1) && y.between?(0, @height - 1)
199
+
200
+ source = packed.dup
201
+ source.setbyte(3, packed.getbyte(3) * coverage / 255)
202
+ blend_pixel(x, y, source)
203
+ end
204
+ self
205
+ end
206
+
207
+ def crop(x, y, width, height)
208
+ result = Image.new(width, height)
209
+ result.blit(self, 0, 0, sx: x, sy: y, w: width, h: height, blend: :copy)
210
+ result
211
+ end
212
+
213
+ def flip_vertical
214
+ result = Image.new(@width, @height, metadata: @metadata)
215
+ (0...@height).each do |y|
216
+ result.instance_variable_get(:@data)[y * @width * 4, @width * 4] = @data.byteslice((@height - y - 1) * @width * 4, @width * 4)
217
+ end
218
+ result
219
+ end
220
+
221
+ def scale_nearest(width, height)
222
+ width, height = Integer(width), Integer(height)
223
+ raise ArgumentError, "dimensions must be non-negative" if width.negative? || height.negative?
224
+
225
+ result = Image.new(width, height, metadata: @metadata)
226
+ return result if width.zero? || height.zero? || @width.zero? || @height.zero?
227
+
228
+ (0...height).each do |y|
229
+ source_y = y * @height / height
230
+ (0...width).each do |x|
231
+ source_x = x * @width / width
232
+ result.instance_variable_get(:@data)[(y * width + x) * 4, 4] = @data.byteslice((source_y * @width + source_x) * 4, 4)
233
+ end
234
+ end
235
+ result
236
+ end
237
+
238
+ def to_rgb_bytes
239
+ @data.unpack("C*").each_slice(4).flat_map { |r, g, b, _a| [r, g, b] }.pack("C*").b
240
+ end
241
+
242
+ def write(path, **options)
243
+ filename = path.respond_to?(:to_path) ? path.to_path : String(path)
244
+ case File.extname(filename).downcase
245
+ when ".png" then File.binwrite(filename, PNG.encode(self, **options))
246
+ when ".ppm", ".pnm" then File.binwrite(filename, PPM.encode(self, **options))
247
+ when ".bmp" then File.binwrite(filename, BMP.encode(self, **options))
248
+ else raise UnsupportedError, "unknown image format: #{filename}"
249
+ end
250
+ filename
251
+ end
252
+
253
+ def initialize_copy(other)
254
+ super
255
+ @data = other.instance_variable_get(:@data).dup
256
+ @metadata = other.metadata.dup
257
+ end
258
+
259
+ private
260
+
261
+ def blend_pixel(x, y, source)
262
+ source_alpha = source.getbyte(3)
263
+ return if source_alpha.zero?
264
+ offset = (y * @width + x) * 4
265
+ if source_alpha == 255
266
+ @data[offset, 4] = source
267
+ return
268
+ end
269
+
270
+ destination_alpha = @data.getbyte(offset + 3)
271
+ output_alpha = source_alpha + destination_alpha * (255 - source_alpha) / 255
272
+ if output_alpha.zero?
273
+ @data[offset, 4] = "\0\0\0\0".b
274
+ return
275
+ end
276
+ 3.times do |channel|
277
+ value = (source.getbyte(channel) * source_alpha + @data.getbyte(offset + channel) * destination_alpha * (255 - source_alpha) / 255) / output_alpha
278
+ @data.setbyte(offset + channel, value)
279
+ end
280
+ @data.setbyte(offset + 3, output_alpha)
281
+ end
282
+ end
283
+
284
+ module_function
285
+
286
+ def decode(bytes, **options)
287
+ bytes = String(bytes).b
288
+ return PNG.decode(bytes, **options) if bytes.start_with?(SIGNATURE)
289
+ return PPM.decode(bytes, **options) if bytes.start_with?("P3", "P6")
290
+ return BMP.decode(bytes, **options) if bytes.start_with?("BM")
291
+
292
+ raise UnsupportedError, "unknown image format"
293
+ end
294
+
295
+ def read(path, **options)
296
+ decode(File.binread(path), **options)
297
+ end
298
+ end
299
+
300
+ module Tessel
301
+ module PNG
302
+ module Chunk
303
+ module_function
304
+
305
+ def write(io, type, data)
306
+ type = String(type).b
307
+ data = String(data).b
308
+ raise ArgumentError, "PNG chunk type must be four bytes" unless type.bytesize == 4
309
+
310
+ io << [data.bytesize].pack("N") << type << data << [Zlib.crc32(type + data)].pack("N")
311
+ end
312
+ end
313
+
314
+ module Filters
315
+ module_function
316
+
317
+ def unfilter(type, row, previous, bpp)
318
+ bytes = row.bytes
319
+ prior = previous ? previous.bytes : Array.new(bytes.length, 0)
320
+ case type
321
+ when 0 then bytes
322
+ when 1 then bytes.each_index { |i| bytes[i] = (bytes[i] + (i >= bpp ? bytes[i - bpp] : 0)) & 255 }
323
+ when 2 then bytes.each_index { |i| bytes[i] = (bytes[i] + prior[i]) & 255 }
324
+ when 3 then bytes.each_index { |i| bytes[i] = (bytes[i] + (((i >= bpp ? bytes[i - bpp] : 0) + prior[i]) / 2)) & 255 }
325
+ when 4 then bytes.each_index { |i| bytes[i] = (bytes[i] + paeth(i >= bpp ? bytes[i - bpp] : 0, prior[i], i >= bpp ? prior[i - bpp] : 0)) & 255 }
326
+ else raise DecodeError, "unknown PNG filter: #{type}"
327
+ end
328
+ bytes.pack("C*").b
329
+ end
330
+
331
+ def filter(type, row, previous, bpp)
332
+ bytes = row.bytes
333
+ prior = previous ? previous.bytes : Array.new(bytes.length, 0)
334
+ bytes.each_index.map do |i|
335
+ left = i >= bpp ? bytes[i - bpp] : 0
336
+ up = prior[i]
337
+ upper_left = i >= bpp ? prior[i - bpp] : 0
338
+ value = case type
339
+ when 0 then bytes[i]
340
+ when 1 then bytes[i] - left
341
+ when 2 then bytes[i] - up
342
+ when 3 then bytes[i] - ((left + up) / 2)
343
+ when 4 then bytes[i] - paeth(left, up, upper_left)
344
+ else raise ArgumentError, "unknown PNG filter: #{type}"
345
+ end
346
+ value & 255
347
+ end.pack("C*").b
348
+ end
349
+
350
+ def paeth(a, b, c)
351
+ p = a + b - c
352
+ pa = (p - a).abs
353
+ pb = (p - b).abs
354
+ pc = (p - c).abs
355
+ pa <= pb && pa <= pc ? a : (pb <= pc ? b : c)
356
+ end
357
+ private_class_method :paeth
358
+ end
359
+
360
+ module Encoder
361
+ module_function
362
+
363
+ def encode(image, color_type: :rgba, filter: :none, level: Zlib::DEFAULT_COMPRESSION, metadata: image.metadata)
364
+ raise TypeError, "image must be a Tessel::Image" unless image.is_a?(Image)
365
+ raise ArgumentError, "PNG dimensions must be positive" unless image.width.positive? && image.height.positive?
366
+ type = { grayscale: 0, gray: 0, rgb: 2, palette: 3, gray_alpha: 4, rgba: 6 }.fetch(color_type.to_sym) { raise UnsupportedError, "unsupported PNG color type" }
367
+ raise UnsupportedError, "palette PNG output needs an explicit palette" if type == 3
368
+
369
+ raw = "".b
370
+ previous = nil
371
+ data = image.instance_variable_get(:@data)
372
+ channels = { 0 => 1, 2 => 3, 4 => 2, 6 => 4 }.fetch(type)
373
+ (0...image.height).each do |y|
374
+ rgba = data.byteslice(y * image.width * 4, image.width * 4).bytes.each_slice(4)
375
+ row = case type
376
+ when 0 then rgba.map { |r, g, b, _a| (0.299 * r + 0.587 * g + 0.114 * b).round }.pack("C*")
377
+ when 2 then rgba.flat_map { |r, g, b, _a| [r, g, b] }.pack("C*")
378
+ when 4 then rgba.flat_map { |r, g, b, a| [(0.299 * r + 0.587 * g + 0.114 * b).round, a] }.pack("C*")
379
+ else rgba.to_a.flatten.pack("C*")
380
+ end
381
+ selected = case filter.to_sym
382
+ when :none then 0
383
+ when :sub then 1
384
+ when :up then 2
385
+ when :average then 3
386
+ when :paeth then 4
387
+ when :adaptive
388
+ (0..4).min_by { |kind| Filters.filter(kind, row, previous, channels).bytes.sum { |byte| byte < 128 ? byte : 256 - byte } }
389
+ else raise ArgumentError, "unknown PNG filter: #{filter}"
390
+ end
391
+ raw << selected.chr << Filters.filter(selected, row, previous, channels)
392
+ previous = row
393
+ end
394
+
395
+ output = SIGNATURE.dup
396
+ Chunk.write(output, "IHDR", [image.width, image.height, 8, type, 0, 0, 0].pack("N2C5"))
397
+ write_metadata(output, metadata)
398
+ Chunk.write(output, "IDAT", Zlib::Deflate.deflate(raw, level))
399
+ Chunk.write(output, "IEND", "")
400
+ output
401
+ end
402
+
403
+ def write_metadata(output, metadata)
404
+ metadata.each do |key, value|
405
+ key = String(key)
406
+ value = String(value)
407
+ if key.bytesize.between?(1, 79) && key.ascii_only? && value.encode("ISO-8859-1")
408
+ Chunk.write(output, "tEXt", key.b + "\0".b + value.encode("ISO-8859-1").b)
409
+ else
410
+ Chunk.write(output, "iTXt", key.encode("UTF-8").b + "\0\0\0\0\0".b + value.encode("UTF-8").b)
411
+ end
412
+ rescue EncodingError
413
+ Chunk.write(output, "iTXt", key.encode("UTF-8").b + "\0\0\0\0\0".b + value.encode("UTF-8").b)
414
+ end
415
+ end
416
+ private_class_method :write_metadata
417
+ end
418
+
419
+ module Decoder
420
+ module_function
421
+
422
+ ADAM7 = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]].freeze
423
+
424
+ def decode(bytes, max_pixels: 16384 * 16384, verify_crc: true)
425
+ bytes = String(bytes).b
426
+ raise DecodeError, "invalid PNG signature" unless bytes.start_with?(SIGNATURE)
427
+
428
+ chunks = []
429
+ offset = SIGNATURE.bytesize
430
+ while offset < bytes.bytesize
431
+ raise DecodeError.new("truncated PNG chunk", offset: offset) if offset + 12 > bytes.bytesize
432
+ length = bytes.byteslice(offset, 4).unpack1("N")
433
+ type = bytes.byteslice(offset + 4, 4)
434
+ raise DecodeError.new("invalid PNG chunk length", offset: offset, chunk: type) if length > 0x7fff_ffff
435
+ raise DecodeError.new("invalid PNG chunk type", offset: offset, chunk: type) unless type.match?(/\A[A-Za-z]{4}\z/)
436
+ data_start = offset + 8
437
+ data_end = data_start + length
438
+ raise DecodeError.new("truncated PNG chunk", offset: offset, chunk: type) if data_end + 4 > bytes.bytesize
439
+ data = bytes.byteslice(data_start, length)
440
+ actual_crc = bytes.byteslice(data_end, 4).unpack1("N")
441
+ if verify_crc && Zlib.crc32(type + data) != actual_crc
442
+ raise DecodeError.new("PNG CRC mismatch", offset: offset, chunk: type)
443
+ end
444
+ chunks << [type, data]
445
+ offset = data_end + 4
446
+ break if type == "IEND"
447
+ end
448
+ raise DecodeError.new("trailing data after IEND", offset: offset) unless offset == bytes.bytesize
449
+ raise DecodeError, "PNG is missing IEND" unless chunks.last&.first == "IEND"
450
+ raise DecodeError, "IEND must be empty" unless chunks.last[1].empty?
451
+ raise DecodeError, "PNG is missing IDAT" unless chunks.any? { |type, _| type == "IDAT" }
452
+ raise DecodeError, "PNG must have one IHDR" unless chunks.count { |type, _| type == "IHDR" } == 1
453
+ idat_index = chunks.index { |type, _| type == "IDAT" }
454
+ %w[PLTE tRNS].each do |type|
455
+ positions = chunks.each_index.select { |index| chunks[index][0] == type }
456
+ raise DecodeError, "duplicate PNG #{type} chunk" if positions.length > 1
457
+ raise DecodeError, "invalid PNG chunk order" if positions.first && positions.first > idat_index
458
+ end
459
+ plte_index = chunks.index { |type, _| type == "PLTE" }
460
+ trns_index = chunks.index { |type, _| type == "tRNS" }
461
+ raise DecodeError, "invalid PNG chunk order" if plte_index && trns_index && trns_index < plte_index
462
+ raise UnsupportedError, "unknown critical PNG chunk" if chunks.any? { |type, _| type.getbyte(0) < 97 && !%w[IHDR PLTE IDAT IEND].include?(type) }
463
+ idat_started = false
464
+ idat_ended = false
465
+ chunks.each do |type, _|
466
+ if type == "IDAT"
467
+ raise DecodeError, "PNG IDAT chunks must be consecutive" if idat_ended
468
+ idat_started = true
469
+ elsif idat_started && type != "IEND"
470
+ idat_ended = true
471
+ end
472
+ end
473
+ parse(chunks, max_pixels: max_pixels)
474
+ rescue Zlib::Error => e
475
+ raise DecodeError, "invalid PNG data: #{e.message}"
476
+ end
477
+
478
+ def parse(chunks, max_pixels:)
479
+ raise DecodeError, "PNG is missing IHDR" unless chunks.first&.first == "IHDR"
480
+ raise DecodeError, "invalid IHDR length" unless chunks.first[1].bytesize == 13
481
+ width, height, bit_depth, color_type, compression, filter_method, interlace = chunks.first[1].unpack("N2C5")
482
+ raise DecodeError, "invalid PNG dimensions" if width.zero? || height.zero?
483
+ raise LimitError, "PNG dimensions exceed limit" if width > 16_384 || height > 16_384
484
+ raise LimitError, "image exceeds pixel limit" if width * height > max_pixels
485
+ raise DecodeError, "unsupported PNG compression or filter method" unless compression.zero? && filter_method.zero?
486
+ valid_depths = { 0 => [1, 2, 4, 8, 16], 2 => [8, 16], 3 => [1, 2, 4, 8], 4 => [8, 16], 6 => [8, 16] }
487
+ raise UnsupportedError, "unsupported PNG color type #{color_type}" unless valid_depths.key?(color_type)
488
+ raise UnsupportedError, "unsupported PNG bit depth" unless valid_depths[color_type].include?(bit_depth)
489
+ raise UnsupportedError, "unsupported PNG interlace method" unless [0, 1].include?(interlace)
490
+
491
+ palette = chunks.find { |type, _| type == "PLTE" }&.last
492
+ transparency = chunks.find { |type, _| type == "tRNS" }&.last
493
+ raise DecodeError, "PNG palette is missing" if color_type == 3 && !palette
494
+ raise DecodeError, "invalid PNG palette" if palette && (palette.empty? || palette.bytesize % 3 != 0 || palette.bytesize > 768)
495
+ raise DecodeError, "PNG palette is not allowed" if palette && [0, 4].include?(color_type)
496
+ raise DecodeError, "PNG palette exceeds bit depth" if palette && color_type == 3 && palette.bytesize / 3 > (1 << bit_depth)
497
+ raise DecodeError, "invalid PNG transparency" if transparency && ((color_type == 3 && (!palette || transparency.bytesize > palette.bytesize / 3)) || (color_type == 0 && transparency.bytesize != 2) || (color_type == 2 && transparency.bytesize != 6) || [4, 6].include?(color_type))
498
+ idat = chunks.select { |type, _| type == "IDAT" }.map(&:last).join
499
+ raise DecodeError, "PNG is missing IDAT" if idat.empty?
500
+ channels = { 0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4 }.fetch(color_type)
501
+ bits_per_pixel = channels * bit_depth
502
+ row_bytes = (width * bits_per_pixel + 7) / 8
503
+ expected = if interlace.zero?
504
+ (row_bytes + 1) * height
505
+ else
506
+ ADAM7.sum do |x_start, y_start, x_step, y_step|
507
+ pass_width = pass_size(width, x_start, x_step)
508
+ pass_height = pass_size(height, y_start, y_step)
509
+ pass_row_bytes = (pass_width * bits_per_pixel + 7) / 8
510
+ pass_width.zero? || pass_height.zero? ? 0 : (pass_row_bytes + 1) * pass_height
511
+ end
512
+ end
513
+ inflated = "".b
514
+ inflater = Zlib::Inflate.new
515
+ begin
516
+ idat_offset = 0
517
+ while idat_offset < idat.bytesize
518
+ inflater.inflate(idat.byteslice(idat_offset, 16_384)) do |part|
519
+ inflated << part
520
+ raise LimitError, "PNG decompressed data exceeds expected size" if inflated.bytesize > expected
521
+ end
522
+ idat_offset += 16_384
523
+ end
524
+ inflater.finish do |part|
525
+ inflated << part
526
+ raise LimitError, "PNG decompressed data exceeds expected size" if inflated.bytesize > expected
527
+ end
528
+ ensure
529
+ inflater.close
530
+ end
531
+ raise DecodeError, "PNG decompressed size mismatch" unless inflated.bytesize == expected
532
+
533
+ rgba = "\0".b * (width * height * 4)
534
+ cursor = 0
535
+ if interlace.zero?
536
+ previous = nil
537
+ (0...height).each do |y|
538
+ filter = inflated.getbyte(cursor)
539
+ row = inflated.byteslice(cursor + 1, row_bytes)
540
+ cursor += row_bytes + 1
541
+ decoded = Filters.unfilter(filter, row, previous, [1, (bits_per_pixel + 7) / 8].max)
542
+ write_row(rgba, width, y, decoded, bit_depth, color_type, palette, transparency, output_width: width)
543
+ previous = decoded
544
+ end
545
+ else
546
+ ADAM7.each do |x_start, y_start, x_step, y_step|
547
+ pass_width = pass_size(width, x_start, x_step)
548
+ pass_height = pass_size(height, y_start, y_step)
549
+ next if pass_width.zero? || pass_height.zero?
550
+ pass_row_bytes = (pass_width * bits_per_pixel + 7) / 8
551
+ previous = nil
552
+ (0...pass_height).each do |pass_y|
553
+ filter = inflated.getbyte(cursor)
554
+ row = inflated.byteslice(cursor + 1, pass_row_bytes)
555
+ cursor += pass_row_bytes + 1
556
+ decoded = Filters.unfilter(filter, row, previous, [1, (bits_per_pixel + 7) / 8].max)
557
+ write_row(rgba, pass_width, pass_y, decoded, bit_depth, color_type, palette, transparency, output_width: width, x_start: x_start, y_start: y_start, x_step: x_step, y_step: y_step)
558
+ previous = decoded
559
+ end
560
+ end
561
+ end
562
+ Image.from_rgba(width, height, rgba, metadata: parse_metadata(chunks))
563
+ end
564
+ private_class_method :parse
565
+
566
+ def pass_size(size, start, step)
567
+ return 0 if start >= size
568
+
569
+ (size - start + step - 1) / step
570
+ end
571
+ private_class_method :pass_size
572
+
573
+ def write_row(output, row_width, pass_y, row, bit_depth, color_type, palette, transparency, output_width:, x_start: 0, y_start: 0, x_step: 1, y_step: 1)
574
+ (0...row_width).each do |source_x|
575
+ pixel = pixel_rgba(row, source_x, bit_depth, color_type, palette, transparency)
576
+ x = x_start + source_x * x_step
577
+ y = y_start + pass_y * y_step
578
+ output[(y * output_width + x) * 4, 4] = pixel
579
+ end
580
+ end
581
+ private_class_method :write_row
582
+
583
+ def pixel_rgba(row, x, bit_depth, color_type, palette, transparency)
584
+ sample = lambda do |index|
585
+ if bit_depth == 16
586
+ row.getbyte(index * 2) * 256 + row.getbyte(index * 2 + 1)
587
+ elsif bit_depth < 8
588
+ per_byte = 8 / bit_depth
589
+ byte = row.getbyte(index / per_byte)
590
+ shift = (per_byte - 1 - index % per_byte) * bit_depth
591
+ (byte >> shift) & ((1 << bit_depth) - 1)
592
+ else
593
+ row.getbyte(index)
594
+ end
595
+ end
596
+ convert = ->(value) { bit_depth == 16 ? ((value * 255 + 32_767) / 65_535) : ((value * 255 + ((1 << bit_depth) - 1) / 2) / ((1 << bit_depth) - 1)) }
597
+ case color_type
598
+ when 0
599
+ gray = sample.call(x)
600
+ transparent = transparency && (bit_depth == 16 ? transparency.unpack1("n") : transparency.unpack1("n") & ((1 << bit_depth) - 1)) == gray
601
+ value = convert.call(gray)
602
+ [value, value, value, transparent ? 0 : 255].pack("C4")
603
+ when 2
604
+ values = 3.times.map { |index| sample.call(x * 3 + index) }
605
+ transparent = transparency && values == transparency.unpack("n3")
606
+ [*values.map { |value| convert.call(value) }, transparent ? 0 : 255].pack("C4")
607
+ when 3
608
+ index = sample.call(x)
609
+ raise DecodeError, "PNG palette index out of range" unless palette && index * 3 + 2 < palette.bytesize
610
+ [*palette.byteslice(index * 3, 3).bytes, transparency&.getbyte(index) || 255].pack("C4")
611
+ when 4
612
+ gray = convert.call(sample.call(x * 2))
613
+ alpha = convert.call(sample.call(x * 2 + 1))
614
+ [gray, gray, gray, alpha].pack("C4")
615
+ when 6
616
+ 4.times.map { |index| convert.call(sample.call(x * 4 + index)) }.pack("C4")
617
+ end
618
+ end
619
+ private_class_method :pixel_rgba
620
+
621
+ def parse_metadata(chunks)
622
+ chunks.each_with_object({}) do |(type, data), metadata|
623
+ case type
624
+ when "tEXt"
625
+ key, value = data.split("\0", 2)
626
+ raise DecodeError, "invalid PNG tEXt chunk" unless key && !key.empty? && value
627
+ metadata[key.force_encoding("ISO-8859-1").encode("UTF-8")] = value.force_encoding("ISO-8859-1").encode("UTF-8")
628
+ when "iTXt"
629
+ key, rest = data.split("\0", 2)
630
+ raise DecodeError, "invalid PNG iTXt chunk" unless key && !key.empty? && rest && rest.bytesize >= 4
631
+ compressed = rest.getbyte(0)
632
+ raise DecodeError, "invalid PNG iTXt compression" unless [0, 1].include?(compressed) && rest.getbyte(1).zero?
633
+ text = rest.byteslice(2..)
634
+ language_end = text.index("\0")
635
+ translated_end = text.index("\0", language_end + 1) if language_end
636
+ raise DecodeError, "invalid PNG iTXt chunk" unless translated_end
637
+ value = text.byteslice(translated_end + 1..)
638
+ value = Zlib::Inflate.inflate(value) if compressed == 1
639
+ value.force_encoding("UTF-8")
640
+ raise DecodeError, "invalid PNG iTXt text" unless value.valid_encoding?
641
+ key.force_encoding("UTF-8")
642
+ raise DecodeError, "invalid PNG iTXt keyword" unless key.valid_encoding?
643
+ metadata[key] = value
644
+ end
645
+ end
646
+ end
647
+ private_class_method :parse_metadata
648
+ end
649
+
650
+ module_function
651
+
652
+ def encode(image, **options)
653
+ Encoder.encode(image, **options)
654
+ end
655
+
656
+ def decode(bytes, **options)
657
+ Decoder.decode(bytes, **options)
658
+ end
659
+ end
660
+
661
+ module PPM
662
+ module_function
663
+
664
+ def encode(image, binary: true)
665
+ rgb = image.to_rgb_bytes
666
+ if binary
667
+ "P6\n#{image.width} #{image.height}\n255\n".b + rgb
668
+ else
669
+ "P3\n#{image.width} #{image.height}\n255\n" + rgb.unpack("C*").each_slice(3).map { |pixel| pixel.join(" ") }.join("\n") + "\n"
670
+ end
671
+ end
672
+
673
+ def decode(bytes, max_pixels: 16384 * 16384)
674
+ bytes = String(bytes).b
675
+ magic = bytes.byteslice(0, 2)
676
+ raise DecodeError, "invalid PPM" unless %w[P3 P6].include?(magic)
677
+ cursor = 2
678
+ token = lambda do
679
+ loop do
680
+ cursor += 1 while cursor < bytes.bytesize && bytes.getbyte(cursor).chr.match?(/\s/)
681
+ if bytes.getbyte(cursor) == 35
682
+ cursor += 1
683
+ cursor += 1 while cursor < bytes.bytesize && bytes.getbyte(cursor) != 10
684
+ else
685
+ break
686
+ end
687
+ end
688
+ start = cursor
689
+ cursor += 1 while cursor < bytes.bytesize && !bytes.getbyte(cursor).chr.match?(/\s/)
690
+ value = bytes.byteslice(start, cursor - start)
691
+ raise DecodeError, "truncated PPM" if value.nil? || value.empty?
692
+ raise DecodeError, "invalid PPM number" unless value.match?(/\A\d+\z/)
693
+ value.to_i
694
+ end
695
+ width, height, max = 3.times.map { token.call }
696
+ raise DecodeError, "invalid PPM dimensions" unless width.positive? && height.positive? && max.between?(1, 65_535)
697
+ raise LimitError, "image exceeds pixel limit" if width * height > max_pixels
698
+ raise DecodeError, "truncated PPM header" if cursor >= bytes.bytesize
699
+ cursor += bytes.byteslice(cursor, 2) == "\r\n" ? 2 : 1
700
+ values = if magic == "P6"
701
+ body = bytes.byteslice(cursor..)
702
+ expected = width * height * 3 * (max < 256 ? 1 : 2)
703
+ raise DecodeError, "invalid PPM pixel data length" unless body.bytesize == expected
704
+ max < 256 ? body.bytes : body.unpack("n*")
705
+ else
706
+ cursor -= 1
707
+ samples = Array.new(width * height * 3) { token.call }
708
+ tail = bytes.byteslice(cursor..)
709
+ raise DecodeError, "extra PPM samples" unless tail.match?(/\A(?:\s+|\#[^\n]*(?:\n|\z))*\z/)
710
+ samples
711
+ end
712
+ raise DecodeError, "PPM sample exceeds maximum" if values.any? { |value| value > max }
713
+ if max != 255
714
+ values.map! { |value| (value * 255.0 / max).round }
715
+ end
716
+ rgba = values.each_slice(3).flat_map { |r, g, b| [r, g, b, 255] }.pack("C*")
717
+ Image.from_rgba(width, height, rgba)
718
+ rescue StandardError => e
719
+ raise e if e.is_a?(DecodeError) || e.is_a?(LimitError)
720
+ raise DecodeError, "invalid PPM: #{e.message}"
721
+ end
722
+ end
723
+
724
+ module BMP
725
+ module_function
726
+
727
+ def encode(image)
728
+ row_size = (image.width * 4 + 3) & ~3
729
+ pixel_size = row_size * image.height
730
+ header = "BM".b + [54 + pixel_size, 0, 0, 54].pack("L<S<S<L<")
731
+ info = [40, image.width, image.height, 1, 32, 0, pixel_size, 2835, 2835, 0, 0].pack("L<l<l<S<S<L<L<l<l<L<L<")
732
+ pixels = (image.height - 1).downto(0).map do |y|
733
+ image.instance_variable_get(:@data).byteslice(y * image.width * 4, image.width * 4).bytes.each_slice(4).map { |r, g, b, a| [b, g, r, a] }.flatten.pack("C*")
734
+ end.join
735
+ header + info + pixels
736
+ end
737
+
738
+ def decode(bytes, max_pixels: 16384 * 16384)
739
+ bytes = String(bytes).b
740
+ raise DecodeError, "invalid BMP" unless bytes.start_with?("BM") && bytes.bytesize >= 54
741
+ offset = bytes.byteslice(10, 4).unpack1("L<")
742
+ header_size = bytes.byteslice(14, 4).unpack1("L<")
743
+ width, height, planes, depth, compression = bytes.byteslice(18, 16).unpack("l<l<S<S<L<")
744
+ raise UnsupportedError, "unsupported BMP" unless header_size >= 40 && planes == 1 && [24, 32].include?(depth) && compression.zero?
745
+ top_down = height.negative?
746
+ height = height.abs
747
+ raise DecodeError, "invalid BMP dimensions" unless width.positive? && height.positive?
748
+ raise LimitError, "image exceeds pixel limit" if width * height > max_pixels
749
+ row_size = ((width * depth + 31) / 32) * 4
750
+ raise DecodeError, "truncated BMP pixel data" if offset < 54 || offset + row_size * height > bytes.bytesize
751
+ output = "".b
752
+ height.times do |row|
753
+ source_y = top_down ? row : height - row - 1
754
+ data = bytes.byteslice(offset + source_y * row_size, width * depth / 8)
755
+ data.bytes.each_slice(depth / 8) { |b, g, r, a| output << [r, g, b, depth == 32 ? a : 255].pack("C4") }
756
+ end
757
+ Image.from_rgba(width, height, output)
758
+ rescue StandardError => e
759
+ raise e if e.is_a?(Tessel::Error) || e.is_a?(ArgumentError)
760
+ raise DecodeError, "invalid BMP: #{e.message}"
761
+ end
762
+ end
763
+ end
data/sig/tessel.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Tessel
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tessel
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ydah
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: A dependency-light PNG reader/writer and shared RGBA8 pixel surface.
13
+ email:
14
+ - t.yudai92@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - CHANGELOG.md
20
+ - LICENSE.txt
21
+ - README.md
22
+ - Rakefile
23
+ - bench/decode_bench.rb
24
+ - bench/encode_bench.rb
25
+ - bench/raster_bench.rb
26
+ - lib/tessel.rb
27
+ - lib/tessel/color.rb
28
+ - lib/tessel/image.rb
29
+ - lib/tessel/png.rb
30
+ - lib/tessel/png/chunk.rb
31
+ - lib/tessel/png/decoder.rb
32
+ - lib/tessel/png/encoder.rb
33
+ - lib/tessel/png/filters.rb
34
+ - lib/tessel/rbgl.rb
35
+ - lib/tessel/version.rb
36
+ - sig/tessel.rbs
37
+ homepage: https://github.com/rbgfx/tessel
38
+ licenses:
39
+ - MIT
40
+ metadata:
41
+ homepage_uri: https://github.com/rbgfx/tessel
42
+ source_code_uri: https://github.com/rbgfx/tessel/tree/main
43
+ rdoc_options: []
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: 3.1.0
51
+ required_rubygems_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ requirements: []
57
+ rubygems_version: 4.0.16
58
+ specification_version: 4
59
+ summary: PNG and RGBA8 image primitives for Ruby graphics
60
+ test_files: []