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 +7 -0
- data/LICENSE +21 -0
- data/README.md +69 -0
- data/bin/pura-ico +220 -0
- data/lib/pura/ico/decoder.rb +236 -0
- data/lib/pura/ico/encoder.rb +79 -0
- data/lib/pura/ico/image.rb +158 -0
- data/lib/pura/ico/version.rb +7 -0
- data/lib/pura-ico.rb +18 -0
- metadata +76 -0
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
|
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: []
|