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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +80 -0
- data/Rakefile +9 -0
- data/bench/decode_bench.rb +11 -0
- data/bench/encode_bench.rb +10 -0
- data/bench/raster_bench.rb +16 -0
- data/lib/tessel/color.rb +3 -0
- data/lib/tessel/image.rb +3 -0
- data/lib/tessel/png/chunk.rb +3 -0
- data/lib/tessel/png/decoder.rb +3 -0
- data/lib/tessel/png/encoder.rb +3 -0
- data/lib/tessel/png/filters.rb +3 -0
- data/lib/tessel/png.rb +3 -0
- data/lib/tessel/rbgl.rb +42 -0
- data/lib/tessel/version.rb +5 -0
- data/lib/tessel.rb +763 -0
- data/sig/tessel.rbs +4 -0
- metadata +60 -0
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
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
|
+
[](https://rubygems.org/gems/tessel)
|
|
4
|
+
[](https://rubygems.org/gems/tessel)
|
|
5
|
+
[](https://github.com/rbgfx/tessel/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.ruby-lang.org/)
|
|
7
|
+
[](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,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
|
data/lib/tessel/color.rb
ADDED
data/lib/tessel/image.rb
ADDED
data/lib/tessel/png.rb
ADDED
data/lib/tessel/rbgl.rb
ADDED
|
@@ -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
|
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
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: []
|