qoi 1.0.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: 3738d729da49df1ab6812d1e56dd6a972262a62e40ae5638e820f8ab5ab68437
4
+ data.tar.gz: 8f9a11be98af90afca9270b4fb613e1b4252c0be9352a584b983ef43d6db06db
5
+ SHA512:
6
+ metadata.gz: 964ae465da63c1f2b1865534a5ba490edd436e853141515435e16cbf4d05bda39a206b87b953eabd31cb04bf5c71212a4d7d555707ee1864dd464956c733bb36
7
+ data.tar.gz: df6ed5e9bf4e425a158cacd695cb4e1d38572aff49c2db090f9dde5d57a6cd5378e610ac8e3a9921d5130f800f86c8725a39142b4aca23909167d7448d4165aa
@@ -0,0 +1,33 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ ruby-versions:
9
+ uses: ruby/actions/.github/workflows/ruby_versions.yml@master
10
+
11
+ test:
12
+ needs: ruby-versions
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ ruby: ${{ fromJson(needs.ruby-versions.outputs.versions) }}
17
+
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - name: Set up Ruby
22
+ uses: ruby/setup-ruby-pkgs@v1
23
+ with:
24
+ ruby-version: ${{ matrix.ruby }}
25
+ apt-get: "haveged libyaml-dev"
26
+ brew: libyaml
27
+ vcpkg: libyaml
28
+
29
+ - name: Install dependencies
30
+ run: bundle install --jobs 4 --retry 3
31
+
32
+ - name: Run tests
33
+ run: bundle exec rake test
@@ -0,0 +1,52 @@
1
+ name: Publish gem to rubygems.org
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ push:
13
+ if: github.repository == 'tenderlove/qoi'
14
+ runs-on: ubuntu-latest
15
+
16
+ environment:
17
+ name: rubygems.org
18
+ url: https://rubygems.org/gems/qoi
19
+
20
+ permissions:
21
+ contents: write
22
+ id-token: write
23
+
24
+ strategy:
25
+ matrix:
26
+ ruby: ["ruby"]
27
+
28
+ steps:
29
+ - name: Harden Runner
30
+ uses: step-security/harden-runner@v2
31
+ with:
32
+ egress-policy: audit
33
+
34
+ - uses: actions/checkout@v4
35
+
36
+ - name: Set up Ruby
37
+ uses: ruby/setup-ruby@v1
38
+ with:
39
+ ruby-version: ${{ matrix.ruby }}
40
+
41
+ - name: Install dependencies
42
+ run: bundle install --jobs 4 --retry 3
43
+
44
+ - name: Publish to RubyGems
45
+ uses: rubygems/release-gem@v1
46
+
47
+ - name: Create GitHub release
48
+ run: |
49
+ tag_name="$(git describe --tags --abbrev=0)"
50
+ gh release create "${tag_name}" --verify-tag --generate-notes
51
+ env:
52
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ test/images
2
+ Gemfile.lock
@@ -0,0 +1,77 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to make participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, sex characteristics, gender identity and expression,
9
+ level of experience, education, socio-economic status, nationality, personal
10
+ appearance, race, religion, or sexual identity and orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies within all project spaces, and it also applies when
49
+ an individual is representing the project or its community in public spaces.
50
+ Examples of representing a project or community include using an official
51
+ project e-mail address, posting via an official social media account, or acting
52
+ as an appointed representative at an online or offline event. Representation of
53
+ a project may be further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at aaron.patterson at gmail.com. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72
+
73
+ [homepage]: https://www.contributor-covenant.org
74
+
75
+ For answers to common questions about this code of conduct, see
76
+ https://www.contributor-covenant.org/faq
77
+
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright Aaron Patterson
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # QOI
2
+
3
+ A Ruby implementation of the [QOI (Quite OK Image)](https://qoiformat.org) format.
4
+
5
+ ## Installation
6
+
7
+ Just add it to your Gemfile!
8
+
9
+ ```ruby
10
+ gem 'qoi'
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### Decoding
16
+
17
+ ```ruby
18
+ require 'qoi'
19
+
20
+ # From file
21
+ image = QOI::Image.from_file("photo.qoi")
22
+
23
+ # From binary string
24
+ image = QOI::Image.from_buffer(qoi_data)
25
+
26
+ # Access image properties
27
+ image.width # => 800
28
+ image.height # => 600
29
+ image.channels # => 4 (RGBA) or 3 (RGB)
30
+ image.colorspace # => 0 (sRGB) or 1 (linear)
31
+ ```
32
+
33
+ ### Encoding
34
+
35
+ ```ruby
36
+ # Create a new image
37
+ image = QOI::Image.new(100, 100, QOI::Channels::RGBA, QOI::Colorspace::SRGB)
38
+
39
+ # Set pixels
40
+ image.set_rgba(0, 0, 255, 0, 0, 255) # red pixel at (0,0)
41
+ image.set_rgb(1, 0, 0, 255, 0) # green pixel at (1,0)
42
+
43
+ # Encode to QOI
44
+ qoi_data = image.encode
45
+ File.binwrite("output.qoi", qoi_data)
46
+ ```
47
+
48
+ ### Converting from PNG with ChunkyPNG
49
+
50
+ ```ruby
51
+ require 'qoi'
52
+ require 'chunky_png'
53
+
54
+ png = ChunkyPNG::Image.from_file("input.png")
55
+ image = QOI::Image.new(png.width, png.height, QOI::Channels::RGBA, QOI::Colorspace::SRGB, png.to_rgba_stream)
56
+ File.binwrite("output.qoi", image.encode)
57
+ ```
58
+
59
+ ### Reading Pixels
60
+
61
+ ```ruby
62
+ image.rgba(x, y) # => [r, g, b, a]
63
+ image.rgb(x, y) # => [r, g, b]
64
+ image.pixel(x, y) # => 0xRRGGBBAA (packed integer)
65
+ image.buffer # => raw pixel data as binary string
66
+ ```
67
+
68
+ ## License
69
+
70
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,57 @@
1
+ ENV["MT_NO_PLUGINS"] = "1"
2
+
3
+ require "rake/testtask"
4
+ require "rake/clean"
5
+ require "bundler"
6
+ Bundler::GemHelper.install_tasks
7
+
8
+ def download from, to
9
+ Dir.mkdir 'tmp' unless File.directory?("tmp")
10
+
11
+ require "net/http"
12
+ require "net/https"
13
+ require "fileutils"
14
+
15
+ FileUtils.mkdir_p File.dirname(to)
16
+
17
+ url = URI.parse from
18
+ client = Net::HTTP.new(url.host, url.port)
19
+ client.use_ssl = true
20
+ client.start do |http|
21
+ http.request_get(url.path) do |res|
22
+ File.open(to, "w") do |f|
23
+ res.read_body do |segment|
24
+ f.write segment
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ TEST_IMAGES_URL = "https://qoiformat.org/qoi_test_images.zip"
32
+ TEST_IMAGES_ZIP = "test/images/tests.zip"
33
+ TEST_IMAGES_FOLDER = "test/images/qoi_test_images"
34
+
35
+ file TEST_IMAGES_FOLDER => TEST_IMAGES_ZIP do |t|
36
+ require "zip"
37
+ Zip::File.open(t.source) do |zip|
38
+ zip.each do |entry|
39
+ entry.extract File.join("test", "images", entry.name)
40
+ end
41
+ end
42
+ end
43
+
44
+ file TEST_IMAGES_ZIP do |t|
45
+ download TEST_IMAGES_URL, t.name
46
+ end
47
+
48
+ CLEAN.include [TEST_IMAGES_ZIP, TEST_IMAGES_FOLDER]
49
+
50
+ Rake::TestTask.new(:test) do |t|
51
+ t.libs << "test"
52
+ t.test_files = FileList['test/**/*_test.rb']
53
+ t.verbose = true
54
+ t.warning = true
55
+ end
56
+
57
+ task :test => TEST_IMAGES_FOLDER
@@ -0,0 +1,3 @@
1
+ module QOI
2
+ VERSION = "1.0.0"
3
+ end
data/lib/qoi.rb ADDED
@@ -0,0 +1,467 @@
1
+ # frozen_string_literal: true
2
+ # encoding: ascii-8bit
3
+
4
+ # QOI (Quite OK Image) format encoder and decoder.
5
+ #
6
+ # QOI is a fast, lossless image compression format that offers decent
7
+ # compression ratios while being simple to implement.
8
+ #
9
+ # @example Decoding a QOI file
10
+ # image = QOI::Image.from_file("photo.qoi")
11
+ # puts "#{image.width}x#{image.height}, #{image.channels} channels"
12
+ #
13
+ # @example Encoding an image to QOI
14
+ # image = QOI::Image.new(100, 100, 4, QOI::Colorspace::SRGB)
15
+ # image.set_rgba(0, 0, 255, 0, 0, 255) # Set pixel at (0,0) to red
16
+ # qoi_data = image.encode
17
+ #
18
+ # @see https://qoiformat.org QOI Format Specification
19
+ module QOI
20
+ autoload :VERSION, "qoi/version"
21
+
22
+ # Exception classes for QOI operations.
23
+ module Errors
24
+ # Base class for all QOI errors.
25
+ class Error < StandardError; end
26
+
27
+ # Raised when the QOI file format is invalid or corrupted.
28
+ class FormatError < Error; end
29
+
30
+ # Raised when the buffer size doesn't match the image dimensions.
31
+ class BufferSizeError < Error; end
32
+ end
33
+
34
+ # Channel count constants for QOI images.
35
+ module Channels
36
+ # 3-channel RGB image (no alpha).
37
+ RGB = 3
38
+
39
+ # 4-channel RGBA image (with alpha).
40
+ RGBA = 4
41
+ end
42
+
43
+ # Colorspace constants for QOI images.
44
+ module Colorspace
45
+ # sRGB with linear alpha.
46
+ SRGB = 0
47
+
48
+ # All channels are linear.
49
+ ALL = 1
50
+ end
51
+
52
+ # Represents a QOI image with pixel data.
53
+ #
54
+ # Images can be created by decoding QOI data or by constructing a new
55
+ # blank image. Pixel data is stored as a binary string in row-major order,
56
+ # with either 3 bytes (RGB) or 4 bytes (RGBA) per pixel.
57
+ class Image
58
+ def self.pixel_hash px # :nodoc:
59
+ r = (px >> 24) & 0xFF
60
+ g = (px >> 16) & 0xFF
61
+ b = (px >> 8) & 0xFF
62
+ a = px & 0xFF
63
+ (r * 3 + g * 5 + b * 7 + a * 11) % 64
64
+ end
65
+
66
+ def self.decode ctx, reader # :nodoc:
67
+ index = 0
68
+ raise Errors::FormatError unless "qoif" == reader.read(ctx, index, 4)
69
+ index += 4
70
+
71
+ width, height, channels, colorspace = reader.read(ctx, index, 10).unpack("NNCC")
72
+ index += 10
73
+
74
+ total_pixels = width * height
75
+ buff = String.new(capacity: total_pixels * channels, encoding: Encoding::BINARY)
76
+ writer = channels == 3 ? RGBWriter : RGBAWriter
77
+
78
+ # Pixel format: 0xRRGGBBAA (RGBA, high to low bits)
79
+ px = 0x000000FF
80
+ seen = Array.new(64, 0)
81
+ seen[pixel_hash(px)] = px
82
+
83
+ pixels_decoded = 0
84
+
85
+ while true
86
+ byte = reader.getbyte(ctx, index)
87
+ index += 1
88
+ break if pixels_decoded >= total_pixels
89
+
90
+ if byte == 0xFE # QOI_OP_RGB
91
+ px = (reader.read_uint24(ctx, index) << 8) | (px & 0xFF)
92
+ index += 3
93
+ seen[pixel_hash(px)] = px
94
+ writer.write(px, buff)
95
+
96
+ elsif byte == 0xFF # QOI_OP_RGBA
97
+ px = reader.read_uint32(ctx, index)
98
+ index += 4
99
+ seen[pixel_hash(px)] = px
100
+ writer.write(px, buff)
101
+
102
+ elsif byte & 0xC0 == 0xC0 # QOI_OP_RUN
103
+ run = byte & 0x3F
104
+ (run + 1).times { writer.write(px, buff) }
105
+ pixels_decoded += run
106
+
107
+ elsif byte & 0xC0 == 0x80 # QOI_OP_LUMA
108
+ dg = (byte & 0x3F) - 32
109
+ byte2 = reader.getbyte(ctx, index); index += 1
110
+ dr_dg = (byte2 >> 4) - 8
111
+ db_dg = (byte2 & 0x0F) - 8
112
+
113
+ r = (((px >> 24) & 0xFF) + dg + dr_dg) & 0xFF
114
+ g = (((px >> 16) & 0xFF) + dg) & 0xFF
115
+ b = (((px >> 8) & 0xFF) + dg + db_dg) & 0xFF
116
+ px = (r << 24) | (g << 16) | (b << 8) | (px & 0xFF)
117
+ seen[pixel_hash(px)] = px
118
+ writer.write(px, buff)
119
+
120
+ elsif byte & 0xC0 == 0x40 # QOI_OP_DIFF
121
+ dr = ((byte >> 4) & 0x03) - 2
122
+ dg = ((byte >> 2) & 0x03) - 2
123
+ db = (byte & 0x03) - 2
124
+
125
+ r = (((px >> 24) & 0xFF) + dr) & 0xFF
126
+ g = (((px >> 16) & 0xFF) + dg) & 0xFF
127
+ b = (((px >> 8) & 0xFF) + db) & 0xFF
128
+ px = (r << 24) | (g << 16) | (b << 8) | (px & 0xFF)
129
+ seen[pixel_hash(px)] = px
130
+ writer.write(px, buff)
131
+
132
+ else # QOI_OP_INDEX
133
+ px = seen[byte]
134
+ writer.write(px, buff)
135
+ end
136
+
137
+ pixels_decoded += 1
138
+ end
139
+
140
+ new width, height, channels, colorspace, buff
141
+ end
142
+
143
+ def self.encode width, height, channels, colorspace, buffer
144
+ out = String.new(capacity: 14 + width * height * 5 + 8, encoding: Encoding::BINARY)
145
+
146
+ # Header
147
+ out << "qoif"
148
+ [width, height, channels, colorspace].pack("NNCC", buffer: out)
149
+
150
+ # Pixel format: 0xRRGGBBAA (RGBA, high to low bits)
151
+ previous_pixel = 0x000000FF
152
+ pixel_lut = Array.new(64, 0)
153
+ reader = channels == 3 ? RGBReader : RGBAReader
154
+
155
+ run = 0
156
+ total_pixels = width * height * channels
157
+ last_pixel = total_pixels - channels
158
+ pos = 0
159
+
160
+ while true
161
+ break unless pos < total_pixels
162
+
163
+ pixel = reader.read(buffer, pos)
164
+
165
+ if pixel == previous_pixel
166
+ run += 1
167
+ if run == 62 || pos == last_pixel
168
+ out << (0xC0 | (run - 1))
169
+ run = 0
170
+ end
171
+ else
172
+ if run > 0
173
+ out << (0xC0 | (run - 1))
174
+ run = 0
175
+ end
176
+
177
+ index = pixel_hash(pixel)
178
+ if pixel_lut[index] == pixel
179
+ out << index
180
+ else
181
+ pixel_lut[index] = pixel
182
+
183
+ # if the alpha is the same
184
+ if (pixel & 0xFF) == (previous_pixel & 0xFF)
185
+ vr = (pixel >> 24) - (previous_pixel >> 24)
186
+ vg = ((pixel >> 16) & 0xFF) - ((previous_pixel >> 16) & 0xFF)
187
+ vb = ((pixel >> 8) & 0xFF) - ((previous_pixel >> 8) & 0xFF)
188
+
189
+ # Handle wraparound (e.g., 255->0 is +1, not -255)
190
+ vr = vr - 256 if vr > 127
191
+ vr = vr + 256 if vr < -127
192
+ vg = vg - 256 if vg > 127
193
+ vg = vg + 256 if vg < -127
194
+ vb = vb - 256 if vb > 127
195
+ vb = vb + 256 if vb < -127
196
+
197
+ vg_r = vr - vg
198
+ vg_b = vb - vg
199
+
200
+ if vr > -3 && vr < 2 && vg > -3 && vg < 2 && vb > -3 && vb < 2
201
+ # QOI_OP_DIFF
202
+ out << (0x40 | ((vr + 2) << 4) | ((vg + 2) << 2) | (vb + 2))
203
+ elsif vg_r > -9 && vg_r < 8 && vg > -33 && vg < 32 && vg_b > -9 && vg_b < 8
204
+ # QOI_OP_LUMA
205
+ [0x80 | (vg + 32), ((vg_r + 8) << 4) | (vg_b + 8)].pack("CC", buffer: out)
206
+ else
207
+ # QOI_OP_RGB
208
+ [0xFE_00_00_00 | (pixel >> 8)].pack("N", buffer: out)
209
+ end
210
+ else
211
+ # QOI_OP_RGBA
212
+ [0xFF, pixel].pack("CN", buffer: out)
213
+ end
214
+ end
215
+ end
216
+
217
+ previous_pixel = pixel
218
+ pos += channels
219
+ end
220
+
221
+ # Flush final run
222
+ if run > 0
223
+ out << (0xC0 | (run - 1)).chr
224
+ end
225
+
226
+ # End marker
227
+ out << "\x00\x00\x00\x00\x00\x00\x00\x01"
228
+
229
+ out
230
+ end
231
+
232
+
233
+ module RGBReader # :nodoc:
234
+ def self.read buff, pos
235
+ (buff.getbyte(pos) << 24) | (buff.getbyte(pos + 1) << 16) | (buff.getbyte(pos + 2) << 8) | 0xFF
236
+ end
237
+
238
+ def self.rgba buff, pos
239
+ [buff.getbyte(pos), buff.getbyte(pos + 1), buff.getbyte(pos + 2), 255]
240
+ end
241
+
242
+ def self.rgb buff, pos
243
+ [buff.getbyte(pos), buff.getbyte(pos + 1), buff.getbyte(pos + 2)]
244
+ end
245
+ end
246
+
247
+ module RGBAReader # :nodoc:
248
+ def self.read buff, pos
249
+ buff.unpack1("N", offset: pos)
250
+ end
251
+
252
+ def self.rgba buff, pos
253
+ [buff.getbyte(pos), buff.getbyte(pos + 1), buff.getbyte(pos + 2), buff.getbyte(pos + 3)]
254
+ end
255
+
256
+ def self.rgb buff, pos
257
+ [buff.getbyte(pos), buff.getbyte(pos + 1), buff.getbyte(pos + 2)]
258
+ end
259
+ end
260
+
261
+ module RGBWriter # :nodoc:
262
+ def self.write px, buff
263
+ buff << ((px >> 24) & 0xFF) << ((px >> 16) & 0xFF) << ((px >> 8) & 0xFF)
264
+ end
265
+
266
+ def self.write_at buff, pos, r, g, b, _a
267
+ buff.setbyte(pos, r)
268
+ buff.setbyte(pos + 1, g)
269
+ buff.setbyte(pos + 2, b)
270
+ end
271
+ end
272
+
273
+ module RGBAWriter # :nodoc:
274
+ def self.write px, buff
275
+ [px].pack("N", buffer: buff)
276
+ end
277
+
278
+ def self.write_at buff, pos, r, g, b, a
279
+ buff.setbyte(pos, r)
280
+ buff.setbyte(pos + 1, g)
281
+ buff.setbyte(pos + 2, b)
282
+ buff.setbyte(pos + 3, a)
283
+ end
284
+ end
285
+
286
+ module FileHelper # :nodoc:
287
+ def self.read_uint24 fh, _
288
+ (fh.getbyte << 16) | (fh.getbyte << 8) | fh.getbyte
289
+ end
290
+
291
+ def self.read_uint32 fh, _
292
+ (fh.getbyte << 24) | (fh.getbyte << 16) | (fh.getbyte << 8) | fh.getbyte
293
+ end
294
+
295
+ def self.read fh, _, size
296
+ fh.read size
297
+ end
298
+
299
+ def self.getbyte fh, _
300
+ fh.getbyte
301
+ end
302
+ end
303
+
304
+ module BufferHelper # :nodoc:
305
+ def self.read_uint24 buf, offset
306
+ (buf.unpack1("C", offset: offset) << 16) |
307
+ buf.unpack1("n", offset: offset + 1)
308
+ end
309
+
310
+ def self.read_uint32 buf, offset
311
+ buf.unpack1("N", offset: offset)
312
+ end
313
+
314
+ def self.read buf, offset, size
315
+ buf.byteslice(offset, size)
316
+ end
317
+
318
+ def self.getbyte buf, offset
319
+ buf.getbyte offset
320
+ end
321
+ end
322
+
323
+ # Decodes a QOI image from a binary string.
324
+ #
325
+ # @param buff [String] Binary string containing QOI-encoded data.
326
+ # @return [Image] The decoded image.
327
+ # @raise [Errors::FormatError] If the data is not valid QOI format.
328
+ def self.from_buffer buff
329
+ decode buff, BufferHelper
330
+ end
331
+
332
+ # Decodes a QOI image from a file.
333
+ #
334
+ # @param name [String] Path to the QOI file.
335
+ # @return [Image] The decoded image.
336
+ # @raise [Errors::FormatError] If the file is not valid QOI format.
337
+ def self.from_file name
338
+ File.open(name, "rb") do |file|
339
+ decode file, FileHelper
340
+ end
341
+ end
342
+
343
+ # @!attribute [r] width
344
+ # @return [Integer] The image width in pixels.
345
+
346
+ # @!attribute [r] height
347
+ # @return [Integer] The image height in pixels.
348
+
349
+ # @!attribute [r] channels
350
+ # @return [Integer] Number of channels (3 for RGB, 4 for RGBA).
351
+
352
+ # @!attribute [r] colorspace
353
+ # @return [Integer] Colorspace identifier (see {Colorspace}).
354
+
355
+ attr_reader :width, :height, :channels, :colorspace
356
+
357
+ # Creates a new QOI image.
358
+ #
359
+ # @param width [Integer] Image width in pixels.
360
+ # @param height [Integer] Image height in pixels.
361
+ # @param channels [Integer] Number of channels (3 or 4).
362
+ # @param colorspace [Integer] Colorspace (see {Colorspace}).
363
+ # @param buffer [String, nil] Raw pixel data, or nil to create a blank image.
364
+ # @raise [Errors::BufferSizeError] If buffer size doesn't match dimensions.
365
+ def initialize width, height, channels, colorspace, buffer = empty_buffer(width, height, channels)
366
+ expected_size = width * height * channels
367
+ if buffer.bytesize != expected_size
368
+ raise Errors::BufferSizeError, "buffer size #{buffer.bytesize} does not match expected size #{expected_size} (#{width}x#{height}x#{channels})"
369
+ end
370
+ @width = width
371
+ @height = height
372
+ @channels = channels
373
+ @colorspace = colorspace
374
+ @buffer = buffer
375
+ @reader = channels == 3 ? RGBReader : RGBAReader
376
+ @writer = channels == 3 ? RGBWriter : RGBAWriter
377
+ end
378
+
379
+ # Encodes the image to QOI format.
380
+ #
381
+ # @return [String] Binary string containing the QOI-encoded image.
382
+ def encode
383
+ Image.encode(@width, @height, @channels, @colorspace, @buffer)
384
+ end
385
+
386
+ # Returns a copy of the raw pixel data.
387
+ #
388
+ # The buffer contains pixels in row-major order, with 3 bytes per pixel
389
+ # for RGB images or 4 bytes per pixel for RGBA images.
390
+ #
391
+ # @return [String] Copy of the pixel data buffer.
392
+ def buffer; @buffer.dup; end
393
+
394
+ # Gets the RGBA values of a pixel.
395
+ #
396
+ # For RGB images, the alpha value is always 255.
397
+ #
398
+ # @param x [Integer] X coordinate (0-indexed from left).
399
+ # @param y [Integer] Y coordinate (0-indexed from top).
400
+ # @return [Array<Integer>] Array of [r, g, b, a] values (0-255 each).
401
+ def rgba x, y
402
+ pos = (y * @width + x) * @channels
403
+ @reader.rgba @buffer, pos
404
+ end
405
+
406
+ # Gets the RGB values of a pixel.
407
+ #
408
+ # @param x [Integer] X coordinate (0-indexed from left).
409
+ # @param y [Integer] Y coordinate (0-indexed from top).
410
+ # @return [Array<Integer>] Array of [r, g, b] values (0-255 each).
411
+ def rgb x, y
412
+ pos = (y * @width + x) * @channels
413
+ @reader.rgb @buffer, pos
414
+ end
415
+
416
+ # Gets a pixel as a packed 32-bit integer.
417
+ #
418
+ # The format is 0xRRGGBBAA (RGBA from high to low bits).
419
+ # For RGB images, the alpha value is always 0xFF.
420
+ #
421
+ # @param x [Integer] X coordinate (0-indexed from left).
422
+ # @param y [Integer] Y coordinate (0-indexed from top).
423
+ # @return [Integer] Packed pixel value.
424
+ def pixel x, y
425
+ pos = (y * @width + x) * @channels
426
+ @reader.read @buffer, pos
427
+ end
428
+
429
+ # Sets a pixel's RGB values.
430
+ #
431
+ # For RGBA images, the alpha is set to 255.
432
+ # For RGB images, only the RGB values are stored.
433
+ #
434
+ # @param x [Integer] X coordinate (0-indexed from left).
435
+ # @param y [Integer] Y coordinate (0-indexed from top).
436
+ # @param r [Integer] Red value (0-255).
437
+ # @param g [Integer] Green value (0-255).
438
+ # @param b [Integer] Blue value (0-255).
439
+ # @return [void]
440
+ def set_rgb x, y, r, g, b
441
+ pos = (y * @width + x) * @channels
442
+ @writer.write_at @buffer, pos, r, g, b, 255
443
+ end
444
+
445
+ # Sets a pixel's RGBA values.
446
+ #
447
+ # For RGB images, the alpha value is ignored.
448
+ #
449
+ # @param x [Integer] X coordinate (0-indexed from left).
450
+ # @param y [Integer] Y coordinate (0-indexed from top).
451
+ # @param r [Integer] Red value (0-255).
452
+ # @param g [Integer] Green value (0-255).
453
+ # @param b [Integer] Blue value (0-255).
454
+ # @param a [Integer] Alpha value (0-255).
455
+ # @return [void]
456
+ def set_rgba x, y, r, g, b, a
457
+ pos = (y * @width + x) * @channels
458
+ @writer.write_at @buffer, pos, r, g, b, a
459
+ end
460
+
461
+ private
462
+
463
+ def empty_buffer width, height, channels
464
+ "\0".b * (width * height * channels)
465
+ end
466
+ end
467
+ end
data/qoi.gemspec ADDED
@@ -0,0 +1,25 @@
1
+ $: << File.expand_path("lib")
2
+
3
+ require "qoi/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "qoi"
7
+ s.version = QOI::VERSION
8
+ s.summary = "Quite OK Image Format Implementation"
9
+ s.description = "Quite OK Image Format Implementation in pure Ruby"
10
+ s.authors = ["Aaron Patterson"]
11
+ s.email = "tenderlove@ruby-lang.org"
12
+ s.files = `git ls-files -z`.split("\x0")
13
+ s.test_files = s.files.grep(%r{^test/})
14
+ s.homepage = "https://github.com/tenderlove/qoi"
15
+ s.license = "Apache-2.0"
16
+
17
+ s.add_development_dependency 'chunky_png', '>= 1.4.0'
18
+ s.add_development_dependency 'minitest', '>= 5.15'
19
+ s.add_development_dependency 'rake', '>= 13.0'
20
+ s.add_development_dependency 'net-http', '>= 0.9.1'
21
+ s.add_development_dependency 'uri', '>= 0.11.1'
22
+ s.add_development_dependency 'rubyzip', '>= 3.2.2'
23
+
24
+ s.required_ruby_version = '>= 3.3.0'
25
+ end
@@ -0,0 +1,30 @@
1
+ require "helper"
2
+
3
+ module QOI
4
+ class DecodeTest < Test
5
+ IMGS = File.join(File.dirname(__FILE__), "images", "qoi_test_images")
6
+
7
+ %w{ dice edgecase kodim10 kodim23
8
+ qoi_logo testcard_rgba testcard wikipedia_008 }.each do |name|
9
+ define_method("test_decode_#{name}_file") do
10
+ png = ChunkyPNG::Image.from_file(File.join(IMGS, name + ".png"))
11
+ buff = QOI::Image.from_file File.join(IMGS, name + ".qoi")
12
+ expected = buff.channels == 3 ? png.to_rgb_stream : png.to_rgba_stream
13
+
14
+ assert_equal png.width, buff.width
15
+ assert_equal png.height, buff.height
16
+ assert_equal expected, buff.buffer
17
+ end
18
+
19
+ define_method("test_decode_#{name}_buffer") do
20
+ png = ChunkyPNG::Image.from_file(File.join(IMGS, name + ".png"))
21
+ buff = QOI::Image.from_buffer File.binread File.join(IMGS, name + ".qoi")
22
+ expected = buff.channels == 3 ? png.to_rgb_stream : png.to_rgba_stream
23
+
24
+ assert_equal png.width, buff.width
25
+ assert_equal png.height, buff.height
26
+ assert_equal expected, buff.buffer
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,30 @@
1
+ require "helper"
2
+
3
+ module QOI
4
+ class EncodeTest < Test
5
+ IMGS = File.join(File.dirname(__FILE__), "images", "qoi_test_images")
6
+
7
+ %w{ dice edgecase kodim10 kodim23
8
+ qoi_logo testcard_rgba testcard wikipedia_008 }.each do |name|
9
+ define_method("test_encoding_#{name}_roundtrip") do
10
+ # Load the PNG and get pixel data
11
+ png = ChunkyPNG::Image.from_file(File.join(IMGS, name + ".png"))
12
+
13
+ # Use channels from reference QOI file
14
+ reference = File.binread(File.join(IMGS, name + ".qoi"))
15
+ channels = reference.getbyte(12)
16
+ original_pixels = channels == 3 ? png.to_rgb_stream : png.to_rgba_stream
17
+
18
+ # Create QOI buffer and encode it
19
+ img = QOI::Image.new(png.width, png.height, channels, 0, original_pixels)
20
+ encoded = img.encode
21
+
22
+ # Verify roundtrip: decode our encoded data and compare pixels
23
+ decoded = QOI::Image.from_buffer(encoded)
24
+ assert_equal png.width, decoded.width, "Width mismatch"
25
+ assert_equal png.height, decoded.height, "Height mismatch"
26
+ assert_equal original_pixels, decoded.buffer, "Pixel data mismatch"
27
+ end
28
+ end
29
+ end
30
+ end
data/test/helper.rb ADDED
@@ -0,0 +1,8 @@
1
+ require "minitest/autorun"
2
+ require "chunky_png"
3
+ require "qoi"
4
+
5
+ module QOI
6
+ class Test < Minitest::Test
7
+ end
8
+ end
@@ -0,0 +1,139 @@
1
+ require "helper"
2
+
3
+ module QOI
4
+ class ImageTest < Test
5
+ IMGS = File.join(File.dirname(__FILE__), "images", "qoi_test_images")
6
+
7
+ def test_rgba_on_rgba_image
8
+ img = QOI::Image.from_file File.join(IMGS, "dice.qoi")
9
+ png = ChunkyPNG::Image.from_file(File.join(IMGS, "dice.png"))
10
+
11
+ # Test a few pixels
12
+ [[0, 0], [10, 10], [img.width - 1, img.height - 1]].each do |x, y|
13
+ expected = ChunkyPNG::Color.to_truecolor_alpha_bytes(png[x, y])
14
+ assert_equal expected, img.rgba(x, y), "RGBA mismatch at (#{x}, #{y})"
15
+ end
16
+ end
17
+
18
+ def test_rgb_on_rgba_image
19
+ img = QOI::Image.from_file File.join(IMGS, "dice.qoi")
20
+ png = ChunkyPNG::Image.from_file(File.join(IMGS, "dice.png"))
21
+
22
+ [[0, 0], [10, 10], [img.width - 1, img.height - 1]].each do |x, y|
23
+ expected = ChunkyPNG::Color.to_truecolor_bytes(png[x, y])
24
+ assert_equal expected, img.rgb(x, y), "RGB mismatch at (#{x}, #{y})"
25
+ end
26
+ end
27
+
28
+ def test_rgba_on_rgb_image
29
+ img = QOI::Image.from_file File.join(IMGS, "kodim10.qoi")
30
+ png = ChunkyPNG::Image.from_file(File.join(IMGS, "kodim10.png"))
31
+
32
+ [[0, 0], [10, 10], [img.width - 1, img.height - 1]].each do |x, y|
33
+ expected = ChunkyPNG::Color.to_truecolor_bytes(png[x, y]) + [255]
34
+ assert_equal expected, img.rgba(x, y), "RGBA mismatch at (#{x}, #{y})"
35
+ end
36
+ end
37
+
38
+ def test_rgb_on_rgb_image
39
+ img = QOI::Image.from_file File.join(IMGS, "kodim10.qoi")
40
+ png = ChunkyPNG::Image.from_file(File.join(IMGS, "kodim10.png"))
41
+
42
+ [[0, 0], [10, 10], [img.width - 1, img.height - 1]].each do |x, y|
43
+ expected = ChunkyPNG::Color.to_truecolor_bytes(png[x, y])
44
+ assert_equal expected, img.rgb(x, y), "RGB mismatch at (#{x}, #{y})"
45
+ end
46
+ end
47
+
48
+ def test_pixel_on_rgba_image
49
+ img = QOI::Image.from_file File.join(IMGS, "dice.qoi")
50
+ png = ChunkyPNG::Image.from_file(File.join(IMGS, "dice.png"))
51
+
52
+ [[0, 0], [10, 10], [img.width - 1, img.height - 1]].each do |x, y|
53
+ r, g, b, a = ChunkyPNG::Color.to_truecolor_alpha_bytes(png[x, y])
54
+ expected = (r << 24) | (g << 16) | (b << 8) | a
55
+ assert_equal expected, img.pixel(x, y), "Pixel mismatch at (#{x}, #{y})"
56
+ end
57
+ end
58
+
59
+ def test_pixel_on_rgb_image
60
+ img = QOI::Image.from_file File.join(IMGS, "kodim10.qoi")
61
+ png = ChunkyPNG::Image.from_file(File.join(IMGS, "kodim10.png"))
62
+
63
+ [[0, 0], [10, 10], [img.width - 1, img.height - 1]].each do |x, y|
64
+ r, g, b = ChunkyPNG::Color.to_truecolor_bytes(png[x, y])
65
+ expected = (r << 24) | (g << 16) | (b << 8) | 0xFF
66
+ assert_equal expected, img.pixel(x, y), "Pixel mismatch at (#{x}, #{y})"
67
+ end
68
+ end
69
+
70
+ def test_set_rgba_on_rgba_image
71
+ img = QOI::Image.new(2, 2, 4, 0)
72
+ img.set_rgba(0, 0, 255, 128, 64, 200)
73
+ img.set_rgba(1, 0, 10, 20, 30, 40)
74
+ img.set_rgba(0, 1, 100, 150, 200, 250)
75
+ img.set_rgba(1, 1, 0, 0, 0, 255)
76
+
77
+ assert_equal [255, 128, 64, 200], img.rgba(0, 0)
78
+ assert_equal [10, 20, 30, 40], img.rgba(1, 0)
79
+ assert_equal [100, 150, 200, 250], img.rgba(0, 1)
80
+ assert_equal [0, 0, 0, 255], img.rgba(1, 1)
81
+ end
82
+
83
+ def test_set_rgb_on_rgba_image
84
+ img = QOI::Image.new(2, 2, 4, 0)
85
+ img.set_rgb(0, 0, 255, 128, 64)
86
+ img.set_rgb(1, 1, 10, 20, 30)
87
+
88
+ assert_equal [255, 128, 64], img.rgb(0, 0)
89
+ assert_equal [10, 20, 30], img.rgb(1, 1)
90
+ # Alpha should be set to 255 by set_rgb
91
+ assert_equal [255, 128, 64, 255], img.rgba(0, 0)
92
+ end
93
+
94
+ def test_set_rgba_on_rgb_image
95
+ img = QOI::Image.new(2, 2, 3, 0)
96
+ img.set_rgba(0, 0, 255, 128, 64, 200)
97
+ img.set_rgba(1, 1, 10, 20, 30, 40)
98
+
99
+ # Alpha is ignored for RGB images
100
+ assert_equal [255, 128, 64], img.rgb(0, 0)
101
+ assert_equal [10, 20, 30], img.rgb(1, 1)
102
+ end
103
+
104
+ def test_set_rgb_on_rgb_image
105
+ img = QOI::Image.new(2, 2, 3, 0)
106
+ img.set_rgb(0, 0, 255, 128, 64)
107
+ img.set_rgb(1, 0, 10, 20, 30)
108
+ img.set_rgb(0, 1, 100, 150, 200)
109
+ img.set_rgb(1, 1, 0, 0, 0)
110
+
111
+ assert_equal [255, 128, 64], img.rgb(0, 0)
112
+ assert_equal [10, 20, 30], img.rgb(1, 0)
113
+ assert_equal [100, 150, 200], img.rgb(0, 1)
114
+ assert_equal [0, 0, 0], img.rgb(1, 1)
115
+ end
116
+
117
+ def test_buffer_size_mismatch_raises_error
118
+ # Buffer too small
119
+ err = assert_raises(QOI::Errors::BufferSizeError) do
120
+ QOI::Image.new(10, 10, 4, 0, "\x00" * 100)
121
+ end
122
+ assert_match(/buffer size 100 does not match expected size 400/, err.message)
123
+
124
+ # Buffer too large
125
+ err = assert_raises(QOI::Errors::BufferSizeError) do
126
+ QOI::Image.new(2, 2, 3, 0, "\x00" * 20)
127
+ end
128
+ assert_match(/buffer size 20 does not match expected size 12/, err.message)
129
+ end
130
+
131
+ def test_buffer_size_correct_does_not_raise
132
+ # Exact size should work - no exception raised
133
+ img1 = QOI::Image.new(10, 10, 4, 0, "\x00" * 400)
134
+ img2 = QOI::Image.new(2, 2, 3, 0, "\x00" * 12)
135
+ assert_equal 10, img1.width
136
+ assert_equal 2, img2.width
137
+ end
138
+ end
139
+ end
metadata ADDED
@@ -0,0 +1,142 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: qoi
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Aaron Patterson
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: chunky_png
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 1.4.0
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 1.4.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '5.15'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '5.15'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: net-http
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 0.9.1
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 0.9.1
68
+ - !ruby/object:Gem::Dependency
69
+ name: uri
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 0.11.1
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 0.11.1
82
+ - !ruby/object:Gem::Dependency
83
+ name: rubyzip
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 3.2.2
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 3.2.2
96
+ description: Quite OK Image Format Implementation in pure Ruby
97
+ email: tenderlove@ruby-lang.org
98
+ executables: []
99
+ extensions: []
100
+ extra_rdoc_files: []
101
+ files:
102
+ - ".github/workflows/ci.yml"
103
+ - ".github/workflows/release.yml"
104
+ - ".gitignore"
105
+ - CODE_OF_CONDUCT.md
106
+ - Gemfile
107
+ - LICENSE
108
+ - README.md
109
+ - Rakefile
110
+ - lib/qoi.rb
111
+ - lib/qoi/version.rb
112
+ - qoi.gemspec
113
+ - test/decode_test.rb
114
+ - test/encode_test.rb
115
+ - test/helper.rb
116
+ - test/image_test.rb
117
+ homepage: https://github.com/tenderlove/qoi
118
+ licenses:
119
+ - Apache-2.0
120
+ metadata: {}
121
+ rdoc_options: []
122
+ require_paths:
123
+ - lib
124
+ required_ruby_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: 3.3.0
129
+ required_rubygems_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ requirements: []
135
+ rubygems_version: 4.1.0.dev
136
+ specification_version: 4
137
+ summary: Quite OK Image Format Implementation
138
+ test_files:
139
+ - test/decode_test.rb
140
+ - test/encode_test.rb
141
+ - test/helper.rb
142
+ - test/image_test.rb