libpng 1.6.58.4-aarch64-linux-ohos
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/.github/workflows/build.yml +211 -0
- data/.github/workflows/release.yml +299 -0
- data/.gitignore +19 -0
- data/.rspec +3 -0
- data/.rubocop.yml +39 -0
- data/CHANGELOG.md +127 -0
- data/CLAUDE.md +110 -0
- data/Gemfile +12 -0
- data/LICENSE.txt +27 -0
- data/README.adoc +238 -0
- data/Rakefile +74 -0
- data/bin/console +5 -0
- data/bin/setup +5 -0
- data/ext/extconf.rb +19 -0
- data/lib/libpng/binding.rb +57 -0
- data/lib/libpng/bytes_per_pixel.rb +43 -0
- data/lib/libpng/chunk_walker.rb +271 -0
- data/lib/libpng/decoded_image.rb +22 -0
- data/lib/libpng/error.rb +8 -0
- data/lib/libpng/libpng16.so +0 -0
- data/lib/libpng/recipe.rb +247 -0
- data/lib/libpng/simplified_decoder.rb +107 -0
- data/lib/libpng/simplified_encoder.rb +98 -0
- data/lib/libpng/standard_encoder.rb +219 -0
- data/lib/libpng/version.rb +16 -0
- data/lib/libpng.rb +188 -0
- data/libpng.gemspec +38 -0
- metadata +90 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Libpng
|
|
4
|
+
# Decodes a PNG byte buffer into raw pixels via libpng's "simplified"
|
|
5
|
+
# read API (png_image_begin_read_from_memory + png_image_finish_read).
|
|
6
|
+
#
|
|
7
|
+
# Also parses the byte buffer via ChunkWalker to populate
|
|
8
|
+
# DecodedImage#bit_depth, #color_type, #interlace (from IHDR),
|
|
9
|
+
# #text (from tEXt/zTXt/iTXt), and #color (from gAMA/cHRM/sRGB/iCCP).
|
|
10
|
+
# The simplified API itself drops these ancillary chunks on read --
|
|
11
|
+
# walking them separately is the only way to surface their content.
|
|
12
|
+
#
|
|
13
|
+
# One instance per decode call. Ractor-safe.
|
|
14
|
+
class SimplifiedDecoder
|
|
15
|
+
def initialize(png, pixel_format: 'RGBA')
|
|
16
|
+
raise Error, 'input PNG buffer is nil' if png.nil?
|
|
17
|
+
raise Error, 'input PNG buffer must be a String' unless png.is_a?(String)
|
|
18
|
+
|
|
19
|
+
@png = png
|
|
20
|
+
@pixel_format = pixel_format
|
|
21
|
+
raise Error, "unknown pixel_format #{@pixel_format.inspect}" unless format_value
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns a Libpng::DecodedImage.
|
|
25
|
+
def call
|
|
26
|
+
fmt = format_value
|
|
27
|
+
img = FFI::MemoryPointer.new(:uint8, PNG_IMAGE_SIZE)
|
|
28
|
+
img.clear
|
|
29
|
+
img.put_uint32(PNG_IMAGE_OFF_VERSION, PNG_IMAGE_VERSION)
|
|
30
|
+
|
|
31
|
+
FFI::MemoryPointer.new(:uint8, @png.bytesize) do |in_buf|
|
|
32
|
+
in_buf.write_bytes(@png)
|
|
33
|
+
ok = Libpng::Binding.png_image_begin_read_from_memory(img, in_buf, @png.bytesize)
|
|
34
|
+
raise Error, "png_image_begin_read_from_memory failed: #{read_message(img)}" if ok.zero?
|
|
35
|
+
|
|
36
|
+
img.put_uint32(PNG_IMAGE_OFF_FORMAT, fmt)
|
|
37
|
+
width = img.get_uint32(PNG_IMAGE_OFF_WIDTH)
|
|
38
|
+
height = img.get_uint32(PNG_IMAGE_OFF_HEIGHT)
|
|
39
|
+
stride = width * BytesPerPixel.for_format(fmt)
|
|
40
|
+
out_size = stride * height
|
|
41
|
+
|
|
42
|
+
FFI::MemoryPointer.new(:uint8, out_size) do |out_buf|
|
|
43
|
+
ok = Libpng::Binding.png_image_finish_read(img, nil, out_buf, stride, nil)
|
|
44
|
+
raise Error, "png_image_finish_read failed: #{read_message(img)}" if ok.zero?
|
|
45
|
+
|
|
46
|
+
return DecodedImage.new(
|
|
47
|
+
width: width,
|
|
48
|
+
height: height,
|
|
49
|
+
format: @pixel_format.to_s.upcase,
|
|
50
|
+
pixels: out_buf.read_bytes(out_size),
|
|
51
|
+
**walker_metadata
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
ensure
|
|
56
|
+
Libpng::Binding.png_image_free(img) unless img.nil? || img.null?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def format_value
|
|
62
|
+
FORMAT_BY_NAME[@pixel_format.to_s.upcase]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Walks the PNG once via ChunkWalker to gather all metadata fields
|
|
66
|
+
# the simplified API does not surface. Each accessor is best-effort:
|
|
67
|
+
# a malformed IHDR or text chunk should not poison the rest of the
|
|
68
|
+
# decode -- empty Hashes / nil values are acceptable fall-throughs.
|
|
69
|
+
def walker_metadata
|
|
70
|
+
walker = ChunkWalker.new(@png)
|
|
71
|
+
ihdr = safe_ihdr(walker)
|
|
72
|
+
{
|
|
73
|
+
bit_depth: ihdr[:bit_depth],
|
|
74
|
+
color_type: ihdr[:color_type],
|
|
75
|
+
interlace: ihdr[:interlace],
|
|
76
|
+
text: safe_text(walker),
|
|
77
|
+
color: safe_color(walker)
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def safe_ihdr(walker)
|
|
82
|
+
walker.ihdr_fields
|
|
83
|
+
rescue ChunkWalker::FormatError
|
|
84
|
+
{}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def safe_text(walker)
|
|
88
|
+
walker.text_chunks
|
|
89
|
+
rescue ChunkWalker::FormatError
|
|
90
|
+
{}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def safe_color(walker)
|
|
94
|
+
walker.color_chunks
|
|
95
|
+
rescue ChunkWalker::FormatError
|
|
96
|
+
{}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def read_message(img)
|
|
100
|
+
s = img.get_bytes(PNG_IMAGE_OFF_MESSAGE, PNG_IMAGE_MESSAGE_BYTES)
|
|
101
|
+
s = s.split("\x00").first || ''
|
|
102
|
+
s.empty? ? '(no message)' : s.force_encoding('UTF-8')
|
|
103
|
+
rescue StandardError
|
|
104
|
+
'(no message)'
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Libpng
|
|
4
|
+
# Encodes raw pixel data as a PNG via libpng's "simplified" write API
|
|
5
|
+
# (png_image_write_to_memory). By default the output is then walked
|
|
6
|
+
# chunk-by-chunk to strip the sRGB/gAMA/cHRM/iCCP chunks the
|
|
7
|
+
# simplified API emits -- this matches the byte layout of the
|
|
8
|
+
# classic libpng write path (libemf2svg's rgb2png).
|
|
9
|
+
#
|
|
10
|
+
# One instance per encode call. Ractor-safe: no shared mutable state.
|
|
11
|
+
class SimplifiedEncoder
|
|
12
|
+
def initialize(width, height, pixels,
|
|
13
|
+
pixel_format: 'RGBA',
|
|
14
|
+
convert_to_8bit: false,
|
|
15
|
+
strip_colorspace: true)
|
|
16
|
+
@width = width
|
|
17
|
+
@height = height
|
|
18
|
+
@pixels = pixels
|
|
19
|
+
@pixel_format = pixel_format
|
|
20
|
+
@convert_to_8bit = convert_to_8bit
|
|
21
|
+
@strip_colorspace = strip_colorspace
|
|
22
|
+
validate!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns a binary String of PNG file bytes.
|
|
26
|
+
def call
|
|
27
|
+
raw = write_via_simplified_api
|
|
28
|
+
@strip_colorspace ? ChunkWalker.new(raw).strip_ancillary : raw
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def validate!
|
|
34
|
+
raise Error, 'width must be positive' unless @width.positive?
|
|
35
|
+
raise Error, 'height must be positive' unless @height.positive?
|
|
36
|
+
raise Error, "unknown pixel_format #{@pixel_format.inspect}" unless format_value
|
|
37
|
+
return unless @pixels.bytesize < expected_size
|
|
38
|
+
|
|
39
|
+
raise Error,
|
|
40
|
+
"pixels too short: expected #{expected_size}, got #{@pixels.bytesize}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def format_value
|
|
44
|
+
FORMAT_BY_NAME[@pixel_format.to_s.upcase]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def bytes_per_pixel
|
|
48
|
+
BytesPerPixel.for_format(format_value)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def expected_size
|
|
52
|
+
@width * bytes_per_pixel * @height
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def write_via_simplified_api
|
|
56
|
+
img = FFI::MemoryPointer.new(:uint8, PNG_IMAGE_SIZE)
|
|
57
|
+
img.clear
|
|
58
|
+
img.put_uint32(PNG_IMAGE_OFF_VERSION, PNG_IMAGE_VERSION)
|
|
59
|
+
img.put_uint32(PNG_IMAGE_OFF_WIDTH, @width)
|
|
60
|
+
img.put_uint32(PNG_IMAGE_OFF_HEIGHT, @height)
|
|
61
|
+
img.put_uint32(PNG_IMAGE_OFF_FORMAT, format_value)
|
|
62
|
+
|
|
63
|
+
stride = @width * bytes_per_pixel
|
|
64
|
+
out_len_ptr = FFI::MemoryPointer.new(:size_t, 1)
|
|
65
|
+
ok = Libpng::Binding.png_image_write_to_memory(img, nil, out_len_ptr,
|
|
66
|
+
@convert_to_8bit ? 1 : 0,
|
|
67
|
+
@pixels, stride, nil)
|
|
68
|
+
if ok.zero?
|
|
69
|
+
msg = read_message(img)
|
|
70
|
+
Libpng::Binding.png_image_free(img)
|
|
71
|
+
raise Error, "png_image_write_to_memory (size query) failed: #{msg}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
out_len = out_len_ptr.read_uint64
|
|
75
|
+
raise Error, 'libpng reported zero-length PNG output' if out_len.zero?
|
|
76
|
+
|
|
77
|
+
buffer = FFI::MemoryPointer.new(:uint8, out_len)
|
|
78
|
+
ok = Libpng::Binding.png_image_write_to_memory(img, buffer, out_len_ptr,
|
|
79
|
+
@convert_to_8bit ? 1 : 0,
|
|
80
|
+
@pixels, stride, nil)
|
|
81
|
+
if ok.zero?
|
|
82
|
+
msg = read_message(img)
|
|
83
|
+
Libpng::Binding.png_image_free(img)
|
|
84
|
+
raise Error, "png_image_write_to_memory (write) failed: #{msg}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
buffer.read_bytes(out_len).tap { Libpng::Binding.png_image_free(img) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def read_message(img)
|
|
91
|
+
s = img.get_bytes(PNG_IMAGE_OFF_MESSAGE, PNG_IMAGE_MESSAGE_BYTES)
|
|
92
|
+
s = s.split("\x00").first || ''
|
|
93
|
+
s.empty? ? '(no message)' : s.force_encoding('UTF-8')
|
|
94
|
+
rescue StandardError
|
|
95
|
+
'(no message)'
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Libpng
|
|
4
|
+
# Encodes raw pixel data via libpng's standard write API
|
|
5
|
+
# (png_create_write_struct -> png_set_IHDR -> png_set_rows ->
|
|
6
|
+
# png_write_png(PNG_TRANSFORM_IDENTITY)). This matches the byte
|
|
7
|
+
# output of the "classic" libpng write path used by libemf2svg's
|
|
8
|
+
# rgb2png, and avoids the sRGB/gAMA chunks the simplified API
|
|
9
|
+
# injects by default.
|
|
10
|
+
#
|
|
11
|
+
# Options:
|
|
12
|
+
# pixel_format: :gray, :ga, :rgb, :rgba, or :palette
|
|
13
|
+
# filter: :default (adaptive), :none, :sub, :up, :avg,
|
|
14
|
+
# :paeth, :all, :adaptive
|
|
15
|
+
# compression_level: 0..9 (default 6 = Z_DEFAULT_COMPRESSION)
|
|
16
|
+
# interlace: :none or :adam7 (default :none)
|
|
17
|
+
# bit_depth: 8 or 16 (default 8; ignored for :palette which
|
|
18
|
+
# is always 8)
|
|
19
|
+
# palette: Array of [r, g, b] or [r, g, b, a] for
|
|
20
|
+
# pixel_format: :palette. 1..256 entries.
|
|
21
|
+
#
|
|
22
|
+
# One instance per encode call. Ractor-safe.
|
|
23
|
+
class StandardEncoder
|
|
24
|
+
def initialize(width, height, pixels,
|
|
25
|
+
pixel_format: 'RGBA',
|
|
26
|
+
filter: :default,
|
|
27
|
+
compression_level: 6,
|
|
28
|
+
interlace: :none,
|
|
29
|
+
bit_depth: 8,
|
|
30
|
+
palette: nil)
|
|
31
|
+
@width = width
|
|
32
|
+
@height = height
|
|
33
|
+
@pixels = pixels
|
|
34
|
+
@pixel_format_sym = coerce_pixel_format(pixel_format)
|
|
35
|
+
@filter_sym = filter.to_sym
|
|
36
|
+
@compression_level = compression_level
|
|
37
|
+
@interlace_sym = interlace.to_sym
|
|
38
|
+
@bit_depth = bit_depth
|
|
39
|
+
@palette = palette
|
|
40
|
+
validate!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns a binary String of PNG file bytes.
|
|
44
|
+
def call
|
|
45
|
+
output = String.new.force_encoding('ASCII-8BIT')
|
|
46
|
+
callbacks = CallbackSet.new(output)
|
|
47
|
+
|
|
48
|
+
png_ptr = FFI::Pointer.new(0)
|
|
49
|
+
info_ptr = FFI::Pointer.new(0)
|
|
50
|
+
begin
|
|
51
|
+
png_ptr = Libpng::Binding.png_create_write_struct(LIBPNG_VER_STRING_C, nil, callbacks.error_fn, nil)
|
|
52
|
+
raise Error, 'png_create_write_struct returned NULL' if png_ptr.null?
|
|
53
|
+
|
|
54
|
+
info_ptr = Libpng::Binding.png_create_info_struct(png_ptr)
|
|
55
|
+
raise Error, 'png_create_info_struct returned NULL' if info_ptr.null?
|
|
56
|
+
|
|
57
|
+
apply_compression(png_ptr)
|
|
58
|
+
Libpng::Binding.png_set_write_fn(png_ptr, nil, callbacks.write_fn, nil)
|
|
59
|
+
Libpng::Binding.png_set_IHDR(png_ptr, info_ptr, @width, @height, effective_bit_depth,
|
|
60
|
+
color_type, interlace_method,
|
|
61
|
+
COMPRESSION_TYPE_DEFAULT, FILTER_TYPE_DEFAULT)
|
|
62
|
+
apply_filter(png_ptr)
|
|
63
|
+
apply_palette(png_ptr, info_ptr) if palette_format?
|
|
64
|
+
|
|
65
|
+
FFI::MemoryPointer.new(:uint8, @pixels.bytesize) do |px|
|
|
66
|
+
px.write_bytes(@pixels)
|
|
67
|
+
FFI::MemoryPointer.new(:pointer, @height) do |rows|
|
|
68
|
+
@height.times do |y|
|
|
69
|
+
rows.put_pointer(y * FFI.type_size(:pointer), px + (y * stride))
|
|
70
|
+
end
|
|
71
|
+
Libpng::Binding.png_set_rows(png_ptr, info_ptr, rows)
|
|
72
|
+
Libpng::Binding.png_write_png(png_ptr, info_ptr, TRANSFORM_IDENTITY, nil)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
output
|
|
77
|
+
ensure
|
|
78
|
+
destroy(png_ptr, info_ptr)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Holds FFI::Function callbacks alive for the duration of the
|
|
85
|
+
# libpng calls. FFI::Function objects are GC'd when no Ruby
|
|
86
|
+
# reference remains; libpng stores the raw function pointer but
|
|
87
|
+
# has no idea the underlying Ruby object needs to stay alive.
|
|
88
|
+
class CallbackSet
|
|
89
|
+
attr_reader :write_fn, :error_fn
|
|
90
|
+
|
|
91
|
+
def initialize(output_accumulator)
|
|
92
|
+
@write_fn = FFI::Function.new(:void, %i[pointer pointer size_t]) do |_, data, len|
|
|
93
|
+
output_accumulator << data.read_bytes(len)
|
|
94
|
+
end
|
|
95
|
+
@error_fn = FFI::Function.new(:void, %i[pointer string]) do |_, msg|
|
|
96
|
+
raise Error, "libpng: #{msg}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def coerce_pixel_format(value)
|
|
102
|
+
sym = value.to_s.downcase.to_sym
|
|
103
|
+
raise Error, "unknown pixel_format #{value.inspect}" unless FORMAT_TO_COLOR_TYPE.key?(sym)
|
|
104
|
+
|
|
105
|
+
sym
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def validate!
|
|
109
|
+
raise Error, 'width must be positive' unless @width.positive?
|
|
110
|
+
raise Error, 'height must be positive' unless @height.positive?
|
|
111
|
+
raise Error, "unknown filter #{@filter_sym.inspect}" unless FILTER_MASK_BY_NAME.key?(@filter_sym)
|
|
112
|
+
raise Error, 'compression_level must be 0..9' unless (0..9).cover?(@compression_level)
|
|
113
|
+
unless INTERLACE_BY_NAME.key?(@interlace_sym)
|
|
114
|
+
raise Error,
|
|
115
|
+
"unknown interlace #{@interlace_sym.inspect} (expected :none or :adam7)"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
validate_bit_depth!
|
|
119
|
+
validate_palette! if palette_format?
|
|
120
|
+
return unless @pixels.bytesize < expected_size
|
|
121
|
+
|
|
122
|
+
raise Error,
|
|
123
|
+
"pixels too short: expected #{expected_size}, got #{@pixels.bytesize}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_bit_depth!
|
|
127
|
+
if palette_format?
|
|
128
|
+
raise Error, 'bit_depth must be 8 for palette format' unless @bit_depth == 8
|
|
129
|
+
elsif !%i[gray rgb rgba].include?(@pixel_format_sym)
|
|
130
|
+
# GA: only 8 supported here for now.
|
|
131
|
+
raise Error, 'bit_depth must be 8' unless @bit_depth == 8
|
|
132
|
+
elsif ![8, 16].include?(@bit_depth)
|
|
133
|
+
raise Error, "bit_depth must be 8 or 16, got #{@bit_depth.inspect}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def validate_palette!
|
|
138
|
+
raise Error, 'palette format requires palette: option' if @palette.nil?
|
|
139
|
+
raise Error, 'palette must be an Array' unless @palette.is_a?(Array)
|
|
140
|
+
raise Error, 'palette must have 1..256 entries' unless (1..256).cover?(@palette.length)
|
|
141
|
+
|
|
142
|
+
@palette.each_with_index do |entry, i|
|
|
143
|
+
next if entry.is_a?(Array) && [3, 4].include?(entry.length) && entry.all? do |v|
|
|
144
|
+
v.is_a?(Integer) && v.between?(0, 255)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
raise Error, "palette[#{i}] must be [r,g,b] or [r,g,b,a] with 0..255 values"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def palette_format?
|
|
152
|
+
@pixel_format_sym == :palette
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def color_type
|
|
156
|
+
FORMAT_TO_COLOR_TYPE[@pixel_format_sym]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def effective_bit_depth
|
|
160
|
+
palette_format? ? 8 : @bit_depth
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def interlace_method
|
|
164
|
+
INTERLACE_BY_NAME[@interlace_sym]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def stride
|
|
168
|
+
@width * bytes_per_pixel
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def bytes_per_pixel
|
|
172
|
+
return 1 if palette_format?
|
|
173
|
+
|
|
174
|
+
BytesPerPixel.for_color_type(color_type, bit_depth: effective_bit_depth)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def expected_size
|
|
178
|
+
stride * @height
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def apply_compression(png_ptr)
|
|
182
|
+
Libpng::Binding.png_set_compression_level(png_ptr, @compression_level)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def apply_filter(png_ptr)
|
|
186
|
+
mask = FILTER_MASK_BY_NAME[@filter_sym]
|
|
187
|
+
Libpng::Binding.png_set_filter(png_ptr, FILTER_HEURISTIC_DEFAULT, mask) if mask
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def apply_palette(png_ptr, info_ptr)
|
|
191
|
+
pal = @palette
|
|
192
|
+
has_alpha = pal.any? { |e| e.length == 4 }
|
|
193
|
+
pal_bytes = pal.map { |e| e.first(3) }.flatten.pack('C*')
|
|
194
|
+
FFI::MemoryPointer.new(:uint8, pal_bytes.bytesize) do |pp|
|
|
195
|
+
pp.write_bytes(pal_bytes)
|
|
196
|
+
Libpng::Binding.png_set_PLTE(png_ptr, info_ptr, pp, pal.length)
|
|
197
|
+
end
|
|
198
|
+
return unless has_alpha
|
|
199
|
+
|
|
200
|
+
alpha_bytes = pal.map { |e| e[3] || 255 }.pack('C*')
|
|
201
|
+
FFI::MemoryPointer.new(:uint8, alpha_bytes.bytesize) do |ap|
|
|
202
|
+
ap.write_bytes(alpha_bytes)
|
|
203
|
+
Libpng::Binding.png_set_tRNS(png_ptr, info_ptr, ap, alpha_bytes.bytesize, nil)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def destroy(png_ptr, info_ptr)
|
|
208
|
+
return if png_ptr.null?
|
|
209
|
+
|
|
210
|
+
FFI::MemoryPointer.new(:pointer) do |pp|
|
|
211
|
+
pp.write_pointer(png_ptr)
|
|
212
|
+
FFI::MemoryPointer.new(:pointer) do |ip|
|
|
213
|
+
ip.write_pointer(info_ptr)
|
|
214
|
+
Libpng::Binding.png_destroy_write_struct(pp, ip)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Libpng
|
|
4
|
+
# Gem version follows the pattern:
|
|
5
|
+
#
|
|
6
|
+
# {LIBPNG_VERSION}.{LIBPNG_RUBY_ITERATION}
|
|
7
|
+
#
|
|
8
|
+
# where LIBPNG_VERSION is the upstream libpng release this gem is
|
|
9
|
+
# built against, and LIBPNG_RUBY_ITERATION is a counter for Ruby-side
|
|
10
|
+
# changes (recipe bug fixes, CI changes, docs) that bump without a
|
|
11
|
+
# new libpng release. The iteration resets to 0 each time
|
|
12
|
+
# LIBPNG_VERSION bumps.
|
|
13
|
+
LIBPNG_VERSION = '1.6.58'
|
|
14
|
+
LIBPNG_RUBY_ITERATION = 4
|
|
15
|
+
VERSION = "#{LIBPNG_VERSION}.#{LIBPNG_RUBY_ITERATION}"
|
|
16
|
+
end
|
data/lib/libpng.rb
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ffi'
|
|
4
|
+
|
|
5
|
+
# Libpng is a Ruby binding for libpng (the official PNG reference library)
|
|
6
|
+
# via FFI. The native library is pre-compiled for each target platform and
|
|
7
|
+
# shipped inside the gem, so no C compiler is required at install time.
|
|
8
|
+
#
|
|
9
|
+
# Public API:
|
|
10
|
+
# Libpng.encode(width, height, rgba_bytes, pixel_format: "RGBA")
|
|
11
|
+
# Libpng.encode_standard(width, height, rgba_bytes, pixel_format: "RGBA", ...)
|
|
12
|
+
# Libpng.decode(png_bytes, pixel_format: "RGBA")
|
|
13
|
+
#
|
|
14
|
+
# All encode/decode state is per-call. Each call constructs a dedicated
|
|
15
|
+
# encoder or decoder instance, runs it, and discards it. Calls from
|
|
16
|
+
# different Ractors do not share state. The FFI function table
|
|
17
|
+
# (Libpng::Binding) loads lazily on first use and is shareable across
|
|
18
|
+
# Ractors.
|
|
19
|
+
module Libpng
|
|
20
|
+
# Version constants live in lib/libpng/version.rb. Autoloaded so the
|
|
21
|
+
# version file isn't loaded until someone references a version constant.
|
|
22
|
+
autoload :LIBPNG_VERSION, 'libpng/version'
|
|
23
|
+
autoload :LIBPNG_RUBY_ITERATION, 'libpng/version'
|
|
24
|
+
autoload :VERSION, 'libpng/version'
|
|
25
|
+
|
|
26
|
+
# Public-facing classes.
|
|
27
|
+
autoload :Error, 'libpng/error'
|
|
28
|
+
autoload :DecodedImage, 'libpng/decoded_image'
|
|
29
|
+
autoload :ChunkWalker, 'libpng/chunk_walker'
|
|
30
|
+
autoload :BytesPerPixel, 'libpng/bytes_per_pixel'
|
|
31
|
+
autoload :SimplifiedEncoder, 'libpng/simplified_encoder'
|
|
32
|
+
autoload :SimplifiedDecoder, 'libpng/simplified_decoder'
|
|
33
|
+
autoload :StandardEncoder, 'libpng/standard_encoder'
|
|
34
|
+
|
|
35
|
+
# FFI bindings. Autoloaded so that requiring `libpng` is cheap and
|
|
36
|
+
# does not dlopen libpng16.{so,dylib,dll} -- ext/extconf.rb needs to
|
|
37
|
+
# require 'libpng' to access Libpng::Recipe during the source-gem
|
|
38
|
+
# build, at which point the shared library doesn't exist yet.
|
|
39
|
+
autoload :Binding, 'libpng/binding'
|
|
40
|
+
|
|
41
|
+
# Build-time recipe (MiniPortile). Only loaded when ext/extconf.rb
|
|
42
|
+
# is invoked during `gem install` of the source ('ruby' platform) gem.
|
|
43
|
+
autoload :Recipe, 'libpng/recipe'
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# PNG_IMAGE_FORMAT_* bit flags (png.h). Used by the simplified API.
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
FORMAT_FLAG_ALPHA = 0x01
|
|
49
|
+
FORMAT_FLAG_COLOR = 0x02
|
|
50
|
+
FORMAT_FLAG_LINEAR = 0x04
|
|
51
|
+
FORMAT_FLAG_COLORMAP = 0x08
|
|
52
|
+
FORMAT_FLAG_BGR = 0x10
|
|
53
|
+
FORMAT_FLAG_AFIRST = 0x20
|
|
54
|
+
FORMAT_FLAG_ASSOCIATED_ALPHA = 0x40
|
|
55
|
+
|
|
56
|
+
FORMAT_GRAY = 0
|
|
57
|
+
FORMAT_GA = FORMAT_FLAG_ALPHA
|
|
58
|
+
FORMAT_AG = FORMAT_FLAG_ALPHA | FORMAT_FLAG_AFIRST
|
|
59
|
+
FORMAT_RGB = FORMAT_FLAG_COLOR
|
|
60
|
+
FORMAT_BGR = FORMAT_FLAG_COLOR | FORMAT_FLAG_BGR
|
|
61
|
+
FORMAT_RGBA = FORMAT_FLAG_COLOR | FORMAT_FLAG_ALPHA
|
|
62
|
+
FORMAT_ARGB = FORMAT_FLAG_COLOR | FORMAT_FLAG_ALPHA | FORMAT_FLAG_AFIRST
|
|
63
|
+
FORMAT_BGRA = FORMAT_FLAG_COLOR | FORMAT_FLAG_ALPHA | FORMAT_FLAG_BGR
|
|
64
|
+
FORMAT_ABGR = FORMAT_FLAG_COLOR | FORMAT_FLAG_ALPHA | FORMAT_FLAG_AFIRST | FORMAT_FLAG_BGR
|
|
65
|
+
|
|
66
|
+
FORMAT_BY_NAME = {
|
|
67
|
+
'GRAY' => FORMAT_GRAY,
|
|
68
|
+
'GRAYSCALE' => FORMAT_GRAY,
|
|
69
|
+
'GA' => FORMAT_GA,
|
|
70
|
+
'AG' => FORMAT_AG,
|
|
71
|
+
'RGB' => FORMAT_RGB,
|
|
72
|
+
'BGR' => FORMAT_BGR,
|
|
73
|
+
'RGBA' => FORMAT_RGBA,
|
|
74
|
+
'ARGB' => FORMAT_ARGB,
|
|
75
|
+
'BGRA' => FORMAT_BGRA,
|
|
76
|
+
'ABGR' => FORMAT_ABGR
|
|
77
|
+
}.freeze
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
# Standard-API color types (png.h).
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
COLOR_MASK_PALETTE = 0x01
|
|
83
|
+
COLOR_MASK_COLOR = 0x02
|
|
84
|
+
COLOR_MASK_ALPHA = 0x04
|
|
85
|
+
|
|
86
|
+
COLOR_TYPE_GRAY = 0
|
|
87
|
+
COLOR_TYPE_PALETTE = COLOR_MASK_COLOR | COLOR_MASK_PALETTE
|
|
88
|
+
COLOR_TYPE_RGB = COLOR_MASK_COLOR
|
|
89
|
+
COLOR_TYPE_RGB_ALPHA = COLOR_MASK_COLOR | COLOR_MASK_ALPHA
|
|
90
|
+
COLOR_TYPE_GRAY_ALPHA = COLOR_MASK_ALPHA
|
|
91
|
+
|
|
92
|
+
FORMAT_TO_COLOR_TYPE = {
|
|
93
|
+
gray: COLOR_TYPE_GRAY,
|
|
94
|
+
ga: COLOR_TYPE_GRAY_ALPHA,
|
|
95
|
+
ag: COLOR_TYPE_GRAY_ALPHA,
|
|
96
|
+
rgb: COLOR_TYPE_RGB,
|
|
97
|
+
rgba: COLOR_TYPE_RGB_ALPHA,
|
|
98
|
+
palette: COLOR_TYPE_PALETTE
|
|
99
|
+
}.freeze
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# png_set_IHDR pass-through constants (png.h).
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
INTERLACE_NONE = 0
|
|
105
|
+
INTERLACE_ADAM7 = 1
|
|
106
|
+
COMPRESSION_TYPE_DEFAULT = 0
|
|
107
|
+
FILTER_TYPE_DEFAULT = 0
|
|
108
|
+
TRANSFORM_IDENTITY = 0x0000
|
|
109
|
+
|
|
110
|
+
INTERLACE_BY_NAME = {
|
|
111
|
+
none: INTERLACE_NONE,
|
|
112
|
+
adam7: INTERLACE_ADAM7
|
|
113
|
+
}.freeze
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
# png_set_filter bitmask (PNG_FILTER_*).
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
FILTER_HEURISTIC_DEFAULT = 0
|
|
119
|
+
FILTER_NONE = 0x08
|
|
120
|
+
FILTER_SUB = 0x10
|
|
121
|
+
FILTER_UP = 0x20
|
|
122
|
+
FILTER_AVG = 0x40
|
|
123
|
+
FILTER_PAETH = 0x80
|
|
124
|
+
FILTER_ALL = FILTER_NONE | FILTER_SUB | FILTER_UP | FILTER_AVG | FILTER_PAETH
|
|
125
|
+
|
|
126
|
+
FILTER_MASK_BY_NAME = {
|
|
127
|
+
default: nil,
|
|
128
|
+
adaptive: FILTER_ALL,
|
|
129
|
+
none: FILTER_NONE,
|
|
130
|
+
sub: FILTER_SUB,
|
|
131
|
+
up: FILTER_UP,
|
|
132
|
+
avg: FILTER_AVG,
|
|
133
|
+
paeth: FILTER_PAETH,
|
|
134
|
+
all: FILTER_ALL
|
|
135
|
+
}.freeze
|
|
136
|
+
|
|
137
|
+
# PNG_LIBPNG_VER_STRING. The C string passed as `user_png_ver` to
|
|
138
|
+
# png_create_write_struct. libpng checks this against its compiled-in
|
|
139
|
+
# version; mismatches return NULL. Must match the libpng16 binary we
|
|
140
|
+
# ship, which is 1.6.58.
|
|
141
|
+
LIBPNG_VER_STRING_C = LIBPNG_VERSION
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
# png_image struct field offsets (bytes). Used by SimplifiedEncoder /
|
|
145
|
+
# SimplifiedDecoder to allocate the png_image struct via a raw
|
|
146
|
+
# FFI::MemoryPointer (rather than FFI::Struct, which has class-level
|
|
147
|
+
# state that isn't shareable across non-main Ractors).
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
PNG_IMAGE_VERSION = 1
|
|
150
|
+
PNG_IMAGE_MESSAGE_BYTES = 64
|
|
151
|
+
PNG_IMAGE_SIZE = FFI.type_size(:pointer) + (7 * 4) + PNG_IMAGE_MESSAGE_BYTES
|
|
152
|
+
PNG_IMAGE_OFF_OPAQUE = 0
|
|
153
|
+
PNG_IMAGE_OFF_VERSION = FFI.type_size(:pointer)
|
|
154
|
+
PNG_IMAGE_OFF_WIDTH = PNG_IMAGE_OFF_VERSION + 4
|
|
155
|
+
PNG_IMAGE_OFF_HEIGHT = PNG_IMAGE_OFF_WIDTH + 4
|
|
156
|
+
PNG_IMAGE_OFF_FORMAT = PNG_IMAGE_OFF_HEIGHT + 4
|
|
157
|
+
PNG_IMAGE_OFF_FLAGS = PNG_IMAGE_OFF_FORMAT + 4
|
|
158
|
+
PNG_IMAGE_OFF_COLORMAP_ENTRIES = PNG_IMAGE_OFF_FLAGS + 4
|
|
159
|
+
PNG_IMAGE_OFF_WARNING_OR_ERROR = PNG_IMAGE_OFF_COLORMAP_ENTRIES + 4
|
|
160
|
+
PNG_IMAGE_OFF_MESSAGE = PNG_IMAGE_OFF_WARNING_OR_ERROR + 4
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
# Public API. Each method constructs a dedicated encoder/decoder
|
|
164
|
+
# instance, runs it, and returns its output. Keeping these as thin
|
|
165
|
+
# dispatchers preserves the existing module-level API while letting
|
|
166
|
+
# the heavy logic live in testable, single-purpose classes.
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
class << self
|
|
169
|
+
# Encode raw pixels via the simplified API. Output is post-
|
|
170
|
+
# processed to strip sRGB/gAMA/cHRM/iCCP chunks unless
|
|
171
|
+
# strip_colorspace: false.
|
|
172
|
+
def encode(width, height, pixels, **opts)
|
|
173
|
+
SimplifiedEncoder.new(width, height, pixels, **opts).call
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Encode raw pixels via the standard write API. Accepts filter,
|
|
177
|
+
# compression_level, interlace, bit_depth, and palette options.
|
|
178
|
+
# Emits only IHDR/IDAT/IEND directly (no chunk stripping needed).
|
|
179
|
+
def encode_standard(width, height, pixels, **opts)
|
|
180
|
+
StandardEncoder.new(width, height, pixels, **opts).call
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Decode a PNG buffer into raw pixels plus IHDR metadata.
|
|
184
|
+
def decode(png, **opts)
|
|
185
|
+
SimplifiedDecoder.new(png, **opts).call
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
data/libpng.gemspec
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/libpng/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'libpng'
|
|
7
|
+
spec.version = Libpng::VERSION
|
|
8
|
+
spec.authors = ['Ribose Inc.']
|
|
9
|
+
spec.email = ['open.source@ribose.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'libpng for Ruby (pre-compiled, FFI-based).'
|
|
12
|
+
spec.description = 'Ruby binding for libpng via FFI. The native ' \
|
|
13
|
+
'libpng16 shared library is pre-compiled for ' \
|
|
14
|
+
'each target platform and shipped inside the ' \
|
|
15
|
+
'gem, so no C compiler is required at install time.'
|
|
16
|
+
spec.homepage = 'https://github.com/claricle/libpng-ruby'
|
|
17
|
+
spec.license = 'BSD-2-Clause'
|
|
18
|
+
spec.required_ruby_version = '>= 2.7.0'
|
|
19
|
+
|
|
20
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
21
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
|
22
|
+
spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/README.adoc#versioning"
|
|
23
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
24
|
+
|
|
25
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
26
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
27
|
+
f.match(%r{\A(?:test|spec|features|tmp|ports)/})
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
spec.bindir = 'exe'
|
|
31
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
32
|
+
spec.require_paths = ['lib']
|
|
33
|
+
|
|
34
|
+
spec.add_dependency 'ffi', '~> 1.0'
|
|
35
|
+
spec.add_dependency 'mini_portile2', '~> 2.8'
|
|
36
|
+
|
|
37
|
+
spec.extensions = ['ext/extconf.rb']
|
|
38
|
+
end
|