pura-ico 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8f4b5a3478ed948443a5bbd2fd6d7a903ef01748ff4590ebd320f47b6464ed13
4
+ data.tar.gz: '094bd466ade32cf24ed437a6d1cc9270c08069522ca0b8cbeaec29f06fbd8410'
5
+ SHA512:
6
+ metadata.gz: 67c54437e766884553ff647d463783e149748e9d83d818e619103f6d02280e3d56a33c8fc606d437bbf67c7feb86daab4f2d3b676517f1b32d697f68dc4deaa5
7
+ data.tar.gz: ab29613ddfae906c32d1429330a36aaa589d7d1ab4df8623daf1ac46c8293ff775bbe6ce121a2bb378d4f9fb0cd515c3d275a7cf27d2bc7f7ad111b64db0e6e6
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 komagata
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # pura-ico
2
+
3
+ A pure Ruby ICO decoder/encoder with zero C extension dependencies.
4
+
5
+ Part of the **pura-*** series — pure Ruby image codec gems.
6
+
7
+ ## Features
8
+
9
+ - ICO decoding and encoding
10
+ - Handles both BMP-style and PNG-style icon entries
11
+ - Multiple icon sizes in a single file
12
+ - Image resizing (bilinear / nearest-neighbor / fit / fill)
13
+ - No native extensions, no FFI, no external dependencies
14
+ - CLI tool included
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ gem install pura-ico
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```ruby
25
+ require "pura-ico"
26
+
27
+ # Decode (extracts the first/largest entry)
28
+ image = Pura::Ico.decode("favicon.ico")
29
+ image.width #=> 32
30
+ image.height #=> 32
31
+ image.pixels #=> Raw RGB byte string
32
+ image.pixel_at(x, y) #=> [r, g, b]
33
+
34
+ # Encode
35
+ Pura::Ico.encode(images, "favicon.ico")
36
+
37
+ # Resize
38
+ thumb = image.resize(16, 16)
39
+ ```
40
+
41
+ ## CLI
42
+
43
+ ```bash
44
+ pura-ico decode favicon.ico --info
45
+ ```
46
+
47
+ ## Why pure Ruby?
48
+
49
+ - **`gem install` and go** — no `brew install`, no `apt install`, no C compiler needed
50
+ - **Works everywhere Ruby works** — CRuby, ruby.wasm, JRuby, TruffleRuby
51
+ - **Both BMP and PNG entries** — handles all common ICO formats
52
+ - **Part of pura-\*** — convert between JPEG, PNG, BMP, GIF, TIFF, WebP, ICO seamlessly
53
+
54
+ ## Related gems
55
+
56
+ | Gem | Format | Status |
57
+ |-----|--------|--------|
58
+ | [pura-jpeg](https://github.com/komagata/pura-jpeg) | JPEG | ✅ Available |
59
+ | [pura-png](https://github.com/komagata/pura-png) | PNG | ✅ Available |
60
+ | [pura-bmp](https://github.com/komagata/pura-bmp) | BMP | ✅ Available |
61
+ | [pura-gif](https://github.com/komagata/pura-gif) | GIF | ✅ Available |
62
+ | [pura-tiff](https://github.com/komagata/pura-tiff) | TIFF | ✅ Available |
63
+ | **pura-ico** | ICO | ✅ Available |
64
+ | [pura-webp](https://github.com/komagata/pura-webp) | WebP | ✅ Available |
65
+ | [pura-image](https://github.com/komagata/pura-image) | All formats | ✅ Available |
66
+
67
+ ## License
68
+
69
+ MIT
data/bin/pura-ico ADDED
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.join("/tmp/pure-png", "lib"))
5
+ require_relative "../lib/pure-ico"
6
+
7
+ def usage
8
+ puts <<~USAGE
9
+ Usage: pure-ico <command> [options]
10
+
11
+ Commands:
12
+ decode <input.ico> [options] Decode an ICO/CUR file
13
+ --info Show image metadata
14
+ --out <file> Write raw RGB data to file
15
+
16
+ encode <input.rgb> [options] Encode raw RGB data to ICO
17
+ --width <n> Image width (required)
18
+ --height <n> Image height (required)
19
+ --sizes <s1,s2,...> Generate multiple sizes (e.g. 16,32,48)
20
+ --out <file> Output ICO file (required)
21
+
22
+ resize <input.ico> [options] Resize an ICO image
23
+ --width <n> Target width
24
+ --height <n> Target height
25
+ --fit <W>x<H> Fit within bounds (maintain aspect ratio)
26
+ --fill <W>x<H> Fill exact size (crop to fit)
27
+ --interpolation <type> bilinear or nearest (default: bilinear)
28
+ --out <file> Output ICO file (required)
29
+
30
+ benchmark <input.ico> Benchmark decoding
31
+
32
+ version Show version
33
+ USAGE
34
+ exit 1
35
+ end
36
+
37
+ def cmd_decode(args)
38
+ input = nil
39
+ info_only = false
40
+ output = nil
41
+
42
+ i = 0
43
+ while i < args.size
44
+ case args[i]
45
+ when "--info"
46
+ info_only = true
47
+ when "--out"
48
+ i += 1
49
+ output = args[i]
50
+ else
51
+ input = args[i]
52
+ end
53
+ i += 1
54
+ end
55
+
56
+ usage unless input
57
+
58
+ unless File.exist?(input)
59
+ $stderr.puts "Error: file not found: #{input}"
60
+ exit 1
61
+ end
62
+
63
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+ image = Pure::Ico.decode(input)
65
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
66
+
67
+ if info_only
68
+ puts "File: #{input}"
69
+ puts "Width: #{image.width}"
70
+ puts "Height: #{image.height}"
71
+ puts "Pixels: #{image.width * image.height}"
72
+ puts "Size: #{File.size(input)} bytes"
73
+ puts "Decode: #{'%.3f' % elapsed}s"
74
+ elsif output
75
+ File.binwrite(output, image.pixels)
76
+ puts "Wrote #{image.pixels.bytesize} bytes of raw RGB to #{output}"
77
+ puts "Decode time: #{'%.3f' % elapsed}s"
78
+ else
79
+ puts "Decoded #{input}: #{image.width}x#{image.height} in #{'%.3f' % elapsed}s"
80
+ end
81
+ end
82
+
83
+ def cmd_encode(args)
84
+ input = nil
85
+ width = nil
86
+ height = nil
87
+ sizes = nil
88
+ output = nil
89
+
90
+ i = 0
91
+ while i < args.size
92
+ case args[i]
93
+ when "--width"
94
+ i += 1; width = args[i].to_i
95
+ when "--height"
96
+ i += 1; height = args[i].to_i
97
+ when "--sizes"
98
+ i += 1; sizes = args[i].split(",").map(&:to_i)
99
+ when "--out"
100
+ i += 1; output = args[i]
101
+ else
102
+ input = args[i]
103
+ end
104
+ i += 1
105
+ end
106
+
107
+ unless input && width && height && output
108
+ $stderr.puts "Error: encode requires input file, --width, --height, and --out"
109
+ usage
110
+ end
111
+
112
+ unless File.exist?(input)
113
+ $stderr.puts "Error: file not found: #{input}"
114
+ exit 1
115
+ end
116
+
117
+ raw = File.binread(input)
118
+ image = Pure::Ico::Image.new(width, height, raw)
119
+
120
+ images = if sizes
121
+ sizes.map { |s| image.resize(s, s) }
122
+ else
123
+ [image]
124
+ end
125
+
126
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
127
+ size = Pure::Ico.encode(images, output)
128
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
129
+
130
+ puts "Encoded #{images.size} image(s) to #{output} (#{size} bytes) in #{'%.3f' % elapsed}s"
131
+ end
132
+
133
+ def cmd_resize(args)
134
+ input = nil
135
+ width = nil
136
+ height = nil
137
+ fit = nil
138
+ fill = nil
139
+ interpolation = :bilinear
140
+ output = nil
141
+
142
+ i = 0
143
+ while i < args.size
144
+ case args[i]
145
+ when "--width"
146
+ i += 1; width = args[i].to_i
147
+ when "--height"
148
+ i += 1; height = args[i].to_i
149
+ when "--fit"
150
+ i += 1; fit = args[i]
151
+ when "--fill"
152
+ i += 1; fill = args[i]
153
+ when "--interpolation"
154
+ i += 1
155
+ interpolation = args[i] == "nearest" ? :nearest : :bilinear
156
+ when "--out"
157
+ i += 1; output = args[i]
158
+ else
159
+ input = args[i]
160
+ end
161
+ i += 1
162
+ end
163
+
164
+ unless input && output
165
+ $stderr.puts "Error: resize requires input file and --out"
166
+ usage
167
+ end
168
+
169
+ unless File.exist?(input)
170
+ $stderr.puts "Error: file not found: #{input}"
171
+ exit 1
172
+ end
173
+
174
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
175
+ image = Pure::Ico.decode(input)
176
+
177
+ if fit
178
+ fw, fh = fit.split("x").map(&:to_i)
179
+ resized = image.resize_fit(fw, fh, interpolation: interpolation)
180
+ elsif fill
181
+ fw, fh = fill.split("x").map(&:to_i)
182
+ resized = image.resize_fill(fw, fh, interpolation: interpolation)
183
+ elsif width && height
184
+ resized = image.resize(width, height, interpolation: interpolation)
185
+ else
186
+ $stderr.puts "Error: specify --width/--height, --fit, or --fill"
187
+ usage
188
+ end
189
+
190
+ size = Pure::Ico.encode([resized], output)
191
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
192
+
193
+ puts "Resized #{image.width}x#{image.height} -> #{resized.width}x#{resized.height} to #{output} (#{size} bytes) in #{'%.3f' % elapsed}s"
194
+ end
195
+
196
+ def cmd_benchmark(args)
197
+ input = args[0]
198
+ usage unless input
199
+
200
+ require_relative "../benchmark/decode_benchmark"
201
+ DecodeBenchmark.run(input)
202
+ end
203
+
204
+ case ARGV[0]
205
+ when "decode"
206
+ cmd_decode(ARGV[1..])
207
+ when "encode"
208
+ cmd_encode(ARGV[1..])
209
+ when "resize"
210
+ cmd_resize(ARGV[1..])
211
+ when "benchmark"
212
+ cmd_benchmark(ARGV[1..])
213
+ when "version", "--version", "-v"
214
+ puts "pure-ico #{Pure::Ico::VERSION}"
215
+ when nil, "help", "--help", "-h"
216
+ usage
217
+ else
218
+ $stderr.puts "Unknown command: #{ARGV[0]}"
219
+ usage
220
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pura
4
+ module Ico
5
+ class DecodeError < StandardError; end
6
+
7
+ class Decoder
8
+ ICO_TYPE = 1
9
+ CUR_TYPE = 2
10
+
11
+ PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10].pack("C8").freeze
12
+
13
+ def self.decode(input)
14
+ data = if input.is_a?(String) && !input.include?("\x00") && input.bytesize < 4096 && File.exist?(input)
15
+ File.binread(input)
16
+ else
17
+ input.b
18
+ end
19
+ new(data).decode
20
+ end
21
+
22
+ def initialize(data)
23
+ @data = data
24
+ @pos = 0
25
+ end
26
+
27
+ def decode
28
+ # Parse ICO header
29
+ read_uint16
30
+ type = read_uint16
31
+ count = read_uint16
32
+
33
+ raise DecodeError, "Not an ICO/CUR file (type=#{type})" unless [ICO_TYPE, CUR_TYPE].include?(type)
34
+
35
+ raise DecodeError, "No images in ICO file" if count.zero?
36
+
37
+ # Parse directory entries
38
+ entries = Array.new(count) { read_directory_entry(type) }
39
+
40
+ # Find the largest image (by pixel area)
41
+ best = entries.max_by { |e| e[:width] * e[:height] }
42
+
43
+ decode_entry(best)
44
+ end
45
+
46
+ private
47
+
48
+ def read_directory_entry(type)
49
+ w = read_uint8
50
+ h = read_uint8
51
+ color_count = read_uint8
52
+ _reserved = read_uint8
53
+
54
+ if type == CUR_TYPE
55
+ hotspot_x = read_uint16
56
+ hotspot_y = read_uint16
57
+ else
58
+ planes = read_uint16
59
+ bpp = read_uint16
60
+ end
61
+
62
+ data_size = read_uint32
63
+ data_offset = read_uint32
64
+
65
+ # Width/height of 0 means 256
66
+ w = 256 if w.zero?
67
+ h = 256 if h.zero?
68
+
69
+ entry = {
70
+ width: w,
71
+ height: h,
72
+ color_count: color_count,
73
+ data_size: data_size,
74
+ data_offset: data_offset
75
+ }
76
+ entry[:planes] = planes if type == ICO_TYPE
77
+ entry[:bpp] = bpp if type == ICO_TYPE
78
+ entry[:hotspot_x] = hotspot_x if type == CUR_TYPE
79
+ entry[:hotspot_y] = hotspot_y if type == CUR_TYPE
80
+ entry
81
+ end
82
+
83
+ def decode_entry(entry)
84
+ offset = entry[:data_offset]
85
+ size = entry[:data_size]
86
+ entry_data = @data.byteslice(offset, size)
87
+
88
+ raise DecodeError, "Entry data truncated" unless entry_data && entry_data.bytesize == size
89
+
90
+ if png_entry?(entry_data)
91
+ decode_png_entry(entry_data)
92
+ else
93
+ decode_bmp_entry(entry_data, entry[:width], entry[:height])
94
+ end
95
+ end
96
+
97
+ def png_entry?(data)
98
+ data.bytesize >= 8 && data.byteslice(0, 8) == PNG_SIGNATURE
99
+ end
100
+
101
+ def decode_png_entry(data)
102
+ # Use Pura::Png if available
103
+ require "pura-png"
104
+ png_image = Pura::Png.decode(data)
105
+ Image.new(png_image.width, png_image.height, png_image.pixels)
106
+ end
107
+
108
+ def decode_bmp_entry(data, dir_width, dir_height)
109
+ pos = 0
110
+
111
+ # BMP info header (BITMAPINFOHEADER - 40 bytes)
112
+ header_size = data.byteslice(pos, 4).unpack1("V")
113
+ bmp_width = data.byteslice(pos + 4, 4).unpack1("V")
114
+ # Height in ICO BMP is doubled (includes AND mask)
115
+ bmp_height = data.byteslice(pos + 8, 4).unpack1("V")
116
+ data.byteslice(pos + 12, 2).unpack1("v")
117
+ bpp = data.byteslice(pos + 14, 2).unpack1("v")
118
+ data.byteslice(pos + 16, 4).unpack1("V")
119
+ _image_size = data.byteslice(pos + 20, 4).unpack1("V")
120
+ # Skip remaining header fields
121
+
122
+ width = bmp_width
123
+ height = bmp_height / 2 # Actual height (BMP height includes AND mask)
124
+
125
+ # Use directory dimensions if BMP header seems wrong
126
+ width = dir_width if width.zero?
127
+ height = dir_height if height.zero?
128
+
129
+ pos = header_size # Skip past BMP header
130
+
131
+ # Read color table if needed
132
+ palette = nil
133
+ if bpp <= 8
134
+ num_colors = 1 << bpp
135
+ palette = Array.new(num_colors)
136
+ num_colors.times do |i|
137
+ b = data.getbyte(pos)
138
+ g = data.getbyte(pos + 1)
139
+ r = data.getbyte(pos + 2)
140
+ _a = data.getbyte(pos + 3)
141
+ palette[i] = [r, g, b]
142
+ pos += 4
143
+ end
144
+ end
145
+
146
+ # Decode pixel data (bottom-up, BMP style)
147
+ stride = (((bpp * width) + 31) / 32) * 4 # Row stride aligned to 4 bytes
148
+ pixels = String.new(encoding: Encoding::BINARY, capacity: width * height * 3)
149
+
150
+ # Read rows bottom-up
151
+ rows = Array.new(height)
152
+ height.times do |y|
153
+ row_offset = pos + (y * stride)
154
+ row = String.new(encoding: Encoding::BINARY, capacity: width * 3)
155
+
156
+ case bpp
157
+ when 32
158
+ width.times do |x|
159
+ px_offset = row_offset + (x * 4)
160
+ b = data.getbyte(px_offset)
161
+ g = data.getbyte(px_offset + 1)
162
+ r = data.getbyte(px_offset + 2)
163
+ # a = data.getbyte(px_offset + 3) # Alpha ignored for RGB output
164
+ row << r.chr << g.chr << b.chr
165
+ end
166
+ when 24
167
+ width.times do |x|
168
+ px_offset = row_offset + (x * 3)
169
+ b = data.getbyte(px_offset)
170
+ g = data.getbyte(px_offset + 1)
171
+ r = data.getbyte(px_offset + 2)
172
+ row << r.chr << g.chr << b.chr
173
+ end
174
+ when 8
175
+ width.times do |x|
176
+ idx = data.getbyte(row_offset + x)
177
+ r, g, b = palette[idx]
178
+ row << r.chr << g.chr << b.chr
179
+ end
180
+ when 4
181
+ width.times do |x|
182
+ byte_offset = row_offset + (x / 2)
183
+ byte = data.getbyte(byte_offset)
184
+ idx = x.even? ? (byte >> 4) & 0x0F : byte & 0x0F
185
+ r, g, b = palette[idx]
186
+ row << r.chr << g.chr << b.chr
187
+ end
188
+ when 1
189
+ width.times do |x|
190
+ byte_offset = row_offset + (x / 8)
191
+ byte = data.getbyte(byte_offset)
192
+ bit = (byte >> (7 - (x % 8))) & 1
193
+ r, g, b = palette[bit]
194
+ row << r.chr << g.chr << b.chr
195
+ end
196
+ else
197
+ raise DecodeError, "Unsupported BMP bit depth: #{bpp}"
198
+ end
199
+
200
+ rows[y] = row
201
+ end
202
+
203
+ # BMP is bottom-up, so reverse row order
204
+ rows.reverse_each do |row|
205
+ pixels << row
206
+ end
207
+
208
+ Image.new(width, height, pixels)
209
+ end
210
+
211
+ def read_uint8
212
+ raise DecodeError, "Unexpected end of data" if @pos + 1 > @data.bytesize
213
+
214
+ val = @data.getbyte(@pos)
215
+ @pos += 1
216
+ val
217
+ end
218
+
219
+ def read_uint16
220
+ raise DecodeError, "Unexpected end of data" if @pos + 2 > @data.bytesize
221
+
222
+ val = @data.byteslice(@pos, 2).unpack1("v")
223
+ @pos += 2
224
+ val
225
+ end
226
+
227
+ def read_uint32
228
+ raise DecodeError, "Unexpected end of data" if @pos + 4 > @data.bytesize
229
+
230
+ val = @data.byteslice(@pos, 4).unpack1("V")
231
+ @pos += 4
232
+ val
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pura
4
+ module Ico
5
+ class Encoder
6
+ def self.encode(images, output_path)
7
+ images = [images] unless images.is_a?(Array)
8
+ encoder = new(images)
9
+ data = encoder.encode
10
+ File.binwrite(output_path, data)
11
+ data.bytesize
12
+ end
13
+
14
+ def initialize(images)
15
+ @images = images
16
+ end
17
+
18
+ def encode
19
+ require "pura-png"
20
+
21
+ count = @images.size
22
+
23
+ # Encode each image as PNG data
24
+ png_blobs = @images.map { |img| encode_png_blob(img) }
25
+
26
+ # Calculate offsets
27
+ # Header: 6 bytes
28
+ # Directory entries: 16 bytes each
29
+ header_size = 6 + (16 * count)
30
+ offsets = []
31
+ current_offset = header_size
32
+ png_blobs.each do |blob|
33
+ offsets << current_offset
34
+ current_offset += blob.bytesize
35
+ end
36
+
37
+ out = String.new(encoding: Encoding::BINARY, capacity: current_offset)
38
+
39
+ # ICO header
40
+ out << [0, 1, count].pack("v3") # reserved=0, type=1 (ICO), count
41
+
42
+ # Directory entries
43
+ @images.each_with_index do |img, i|
44
+ w = img.width >= 256 ? 0 : img.width
45
+ h = img.height >= 256 ? 0 : img.height
46
+
47
+ out << [
48
+ w, # width (0 = 256)
49
+ h, # height (0 = 256)
50
+ 0, # color count (0 for >= 256 colors)
51
+ 0, # reserved
52
+ 1, # color planes
53
+ 32 # bits per pixel
54
+ ].pack("C4v2")
55
+ out << [
56
+ png_blobs[i].bytesize, # data size
57
+ offsets[i] # data offset
58
+ ].pack("V2")
59
+ end
60
+
61
+ # Image data (PNG blobs)
62
+ png_blobs.each { |blob| out << blob }
63
+
64
+ out
65
+ end
66
+
67
+ private
68
+
69
+ def encode_png_blob(image)
70
+ # Create a Pura::Png::Image and encode to memory
71
+ png_image = Pura::Png::Image.new(image.width, image.height, image.pixels)
72
+ # Encode to a temporary buffer via StringIO-like approach
73
+ # Pura::Png::Encoder returns binary data via encode method
74
+ encoder = Pura::Png::Encoder.new(png_image)
75
+ encoder.encode
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pura
4
+ module Ico
5
+ class Image
6
+ attr_reader :width, :height, :pixels
7
+
8
+ def initialize(width, height, pixels)
9
+ @width = width
10
+ @height = height
11
+ @pixels = pixels.b
12
+ expected = width * height * 3
13
+ return if @pixels.bytesize == expected
14
+
15
+ raise ArgumentError, "pixel data size #{@pixels.bytesize} != expected #{expected} (#{width}x#{height}x3)"
16
+ end
17
+
18
+ def to_rgb_array
19
+ result = Array.new(width * height)
20
+ i = 0
21
+ offset = 0
22
+ while offset < @pixels.bytesize
23
+ result[i] = [@pixels.getbyte(offset), @pixels.getbyte(offset + 1), @pixels.getbyte(offset + 2)]
24
+ i += 1
25
+ offset += 3
26
+ end
27
+ result
28
+ end
29
+
30
+ def pixel_at(x, y)
31
+ raise IndexError, "coordinates out of bounds" if x.negative? || x >= @width || y.negative? || y >= @height
32
+
33
+ offset = ((y * @width) + x) * 3
34
+ [@pixels.getbyte(offset), @pixels.getbyte(offset + 1), @pixels.getbyte(offset + 2)]
35
+ end
36
+
37
+ def to_ppm
38
+ header = "P6\n#{@width} #{@height}\n255\n"
39
+ header.b + @pixels
40
+ end
41
+
42
+ def resize(new_width, new_height, interpolation: :bilinear)
43
+ raise ArgumentError, "width must be positive" unless new_width.positive?
44
+ raise ArgumentError, "height must be positive" unless new_height.positive?
45
+
46
+ if interpolation == :nearest
47
+ resize_nearest(new_width, new_height)
48
+ else
49
+ resize_bilinear(new_width, new_height)
50
+ end
51
+ end
52
+
53
+ def resize_fit(max_width, max_height, interpolation: :bilinear)
54
+ raise ArgumentError, "max_width must be positive" unless max_width.positive?
55
+ raise ArgumentError, "max_height must be positive" unless max_height.positive?
56
+
57
+ scale = [max_width.to_f / @width, max_height.to_f / @height].min
58
+ scale = [scale, 1.0].min
59
+ new_width = (@width * scale).round
60
+ new_height = (@height * scale).round
61
+ new_width = 1 if new_width < 1
62
+ new_height = 1 if new_height < 1
63
+ resize(new_width, new_height, interpolation: interpolation)
64
+ end
65
+
66
+ def resize_fill(fill_width, fill_height, interpolation: :bilinear)
67
+ raise ArgumentError, "width must be positive" unless fill_width.positive?
68
+ raise ArgumentError, "height must be positive" unless fill_height.positive?
69
+
70
+ scale = [fill_width.to_f / @width, fill_height.to_f / @height].max
71
+ scaled_w = (@width * scale).round
72
+ scaled_h = (@height * scale).round
73
+ scaled_w = 1 if scaled_w < 1
74
+ scaled_h = 1 if scaled_h < 1
75
+
76
+ scaled = resize(scaled_w, scaled_h, interpolation: interpolation)
77
+
78
+ crop_x = (scaled_w - fill_width) / 2
79
+ crop_y = (scaled_h - fill_height) / 2
80
+ scaled.crop(crop_x, crop_y, fill_width, fill_height)
81
+ end
82
+
83
+ def crop(x, y, w, h)
84
+ out = String.new(encoding: Encoding::BINARY, capacity: w * h * 3)
85
+ h.times do |row|
86
+ src_offset = (((y + row) * @width) + x) * 3
87
+ out << @pixels.byteslice(src_offset, w * 3)
88
+ end
89
+ Image.new(w, h, out)
90
+ end
91
+
92
+ private
93
+
94
+ def resize_nearest(new_width, new_height)
95
+ out = String.new(encoding: Encoding::BINARY, capacity: new_width * new_height * 3)
96
+ x_ratio = @width.to_f / new_width
97
+ y_ratio = @height.to_f / new_height
98
+
99
+ new_height.times do |y|
100
+ src_y = (y * y_ratio).to_i
101
+ src_y = @height - 1 if src_y >= @height
102
+ new_width.times do |x|
103
+ src_x = (x * x_ratio).to_i
104
+ src_x = @width - 1 if src_x >= @width
105
+ offset = ((src_y * @width) + src_x) * 3
106
+ out << @pixels.byteslice(offset, 3)
107
+ end
108
+ end
109
+
110
+ Image.new(new_width, new_height, out)
111
+ end
112
+
113
+ def resize_bilinear(new_width, new_height)
114
+ out = String.new(encoding: Encoding::BINARY, capacity: new_width * new_height * 3)
115
+ x_ratio = (@width - 1).to_f / [new_width - 1, 1].max
116
+ y_ratio = (@height - 1).to_f / [new_height - 1, 1].max
117
+
118
+ new_height.times do |y|
119
+ src_y = y * y_ratio
120
+ y0 = src_y.to_i
121
+ y1 = [y0 + 1, @height - 1].min
122
+ y_frac = src_y - y0
123
+
124
+ new_width.times do |x|
125
+ src_x = x * x_ratio
126
+ x0 = src_x.to_i
127
+ x1 = [x0 + 1, @width - 1].min
128
+ x_frac = src_x - x0
129
+
130
+ off00 = ((y0 * @width) + x0) * 3
131
+ off10 = ((y0 * @width) + x1) * 3
132
+ off01 = ((y1 * @width) + x0) * 3
133
+ off11 = ((y1 * @width) + x1) * 3
134
+
135
+ 3.times do |c|
136
+ v00 = @pixels.getbyte(off00 + c)
137
+ v10 = @pixels.getbyte(off10 + c)
138
+ v01 = @pixels.getbyte(off01 + c)
139
+ v11 = @pixels.getbyte(off11 + c)
140
+
141
+ val = (v00 * (1 - x_frac) * (1 - y_frac)) +
142
+ (v10 * x_frac * (1 - y_frac)) +
143
+ (v01 * (1 - x_frac) * y_frac) +
144
+ (v11 * x_frac * y_frac)
145
+
146
+ val = val.round
147
+ val = 0 if val.negative?
148
+ val = 255 if val > 255
149
+ out << val.chr
150
+ end
151
+ end
152
+ end
153
+
154
+ Image.new(new_width, new_height, out)
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pura
4
+ module Ico
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/pura-ico.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pura/ico/version"
4
+ require_relative "pura/ico/image"
5
+ require_relative "pura/ico/decoder"
6
+ require_relative "pura/ico/encoder"
7
+
8
+ module Pura
9
+ module Ico
10
+ def self.decode(input)
11
+ Decoder.decode(input)
12
+ end
13
+
14
+ def self.encode(images, output_path)
15
+ Encoder.encode(images, output_path)
16
+ end
17
+ end
18
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pura-ico
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - komagata
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: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ description: A pure Ruby ICO and CUR decoder and encoder with zero C extension dependencies.
41
+ Supports BMP and PNG icon entries, multiple bit depths, and multi-size ICO files.
42
+ executables:
43
+ - pura-ico
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - LICENSE
48
+ - README.md
49
+ - bin/pura-ico
50
+ - lib/pura-ico.rb
51
+ - lib/pura/ico/decoder.rb
52
+ - lib/pura/ico/encoder.rb
53
+ - lib/pura/ico/image.rb
54
+ - lib/pura/ico/version.rb
55
+ homepage: https://github.com/komagata/pure-ico
56
+ licenses:
57
+ - MIT
58
+ metadata: {}
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: 3.0.0
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.6.9
74
+ specification_version: 4
75
+ summary: Pure Ruby ICO/CUR decoder/encoder
76
+ test_files: []