Module: Libpng

Extended by:
FFI::Library
Defined in:
lib/libpng.rb,
lib/libpng/recipe.rb,
lib/libpng/version.rb

Overview

Libpng is a Ruby binding for libpng (the official PNG reference library) via FFI. The native library is pre-compiled for each target platform and shipped inside the gem, so no C compiler is required at install time.

The API mirrors libpng's "simplified" high-level API:

Libpng.encode(width, height, rgba_bytes, pixel_format: "RGBA")
Libpng.decode(png_bytes, pixel_format: "RGBA")

All encode/decode state is per-call (libpng allocates and frees a png_image internally), so calls from different Ractors do not share state. The module's FFI function table is frozen at load time.

Defined Under Namespace

Classes: DecodedImage, Error, Recipe

Constant Summary collapse

FORMAT_FLAG_ALPHA =

PNG_IMAGE_FORMAT_* bit flags and named formats from png.h.

0x01
FORMAT_FLAG_COLOR =
0x02
FORMAT_FLAG_LINEAR =
0x04
FORMAT_FLAG_COLORMAP =
0x08
FORMAT_FLAG_BGR =
0x10
FORMAT_FLAG_AFIRST =
0x20
FORMAT_FLAG_ASSOCIATED_ALPHA =
0x40
FORMAT_GRAY =
0
FORMAT_GA =
FORMAT_FLAG_ALPHA
FORMAT_AG =
FORMAT_FLAG_ALPHA | FORMAT_FLAG_AFIRST
FORMAT_RGB =
FORMAT_FLAG_COLOR
FORMAT_BGR =
FORMAT_FLAG_COLOR | FORMAT_FLAG_BGR
FORMAT_RGBA =
FORMAT_FLAG_COLOR | FORMAT_FLAG_ALPHA
FORMAT_ARGB =
FORMAT_FLAG_COLOR | FORMAT_FLAG_ALPHA | FORMAT_FLAG_AFIRST
FORMAT_BGRA =
FORMAT_FLAG_COLOR | FORMAT_FLAG_ALPHA | FORMAT_FLAG_BGR
FORMAT_ABGR =
FORMAT_FLAG_COLOR | FORMAT_FLAG_ALPHA | FORMAT_FLAG_AFIRST | FORMAT_FLAG_BGR
FORMAT_BY_NAME =
{
  'GRAY' => FORMAT_GRAY,
  'GRAYSCALE' => FORMAT_GRAY,
  'GA' => FORMAT_GA,
  'AG' => FORMAT_AG,
  'RGB' => FORMAT_RGB,
  'BGR' => FORMAT_BGR,
  'RGBA' => FORMAT_RGBA,
  'ARGB' => FORMAT_ARGB,
  'BGRA' => FORMAT_BGRA,
  'ABGR' => FORMAT_ABGR
}.freeze
COLOR_MASK_PALETTE =

PNG_COLOR_MASK_* and PNG_COLOR_TYPE_* (png.h).

0x01
COLOR_MASK_COLOR =
0x02
COLOR_MASK_ALPHA =
0x04
COLOR_TYPE_GRAY =
0
COLOR_TYPE_PALETTE =
COLOR_MASK_COLOR | COLOR_MASK_PALETTE
COLOR_TYPE_RGB =
COLOR_MASK_COLOR
COLOR_TYPE_RGB_ALPHA =
COLOR_MASK_COLOR | COLOR_MASK_ALPHA
COLOR_TYPE_GRAY_ALPHA =
COLOR_MASK_ALPHA
COLOR_TYPE_BY_FORMAT =

Map our FORMAT_* to PNG_COLOR_TYPE_*. encode_standard doesn't support the BGR/ARGB/BGRA/ABGR byte-order variants because the standard API encodes host-order; callers wanting those formats should use the simplified encode path or pre-swap bytes.

{
  FORMAT_GRAY => COLOR_TYPE_GRAY,
  FORMAT_GA => COLOR_TYPE_GRAY_ALPHA,
  FORMAT_AG => COLOR_TYPE_GRAY_ALPHA,
  FORMAT_RGB => COLOR_TYPE_RGB,
  FORMAT_RGBA => COLOR_TYPE_RGB_ALPHA
}.freeze
INTERLACE_NONE =

png_set_IHDR pass-through constants (png.h).

0
INTERLACE_ADAM7 =
1
COMPRESSION_TYPE_DEFAULT =
0
FILTER_TYPE_DEFAULT =
0
TRANSFORM_IDENTITY =
0x0000
FILTER_HEURISTIC_DEFAULT =

png_set_filter bitmask (PNG_FILTER_*). :default lets libpng use adaptive filtering across all filter types -- this is what libemf2svg/rgb2png effectively does via PNG_FILTER_TYPE_DEFAULT in the IHDR.

0
FILTER_NONE =
0x08
FILTER_SUB =
0x10
FILTER_UP =
0x20
FILTER_AVG =
0x40
FILTER_PAETH =
0x80
FILTER_ALL =
FILTER_NONE | FILTER_SUB | FILTER_UP | FILTER_AVG | FILTER_PAETH
FILTER_MASK_BY_NAME =
{
  default: nil,           # don't call png_set_filter at all
  adaptive: FILTER_ALL,   # call with all filters allowed (same as default)
  none: FILTER_NONE,
  sub: FILTER_SUB,
  up: FILTER_UP,
  avg: FILTER_AVG,
  paeth: FILTER_PAETH,
  all: FILTER_ALL
}.freeze
LIBPNG_VER_STRING_C =

PNG_LIBPNG_VER_STRING. Used as the user_png_ver arg to png_create_write_struct. Must match the libpng16.so,dylib,dll we ship -- if these ever drift, png_create_write_struct aborts.

Libpng::LIBPNG_VERSION.dup.freeze
PNG_IMAGE_VERSION =

png_image::version value libpng checks for. Defined in png.h as PNG_IMAGE_VERSION == 1.

1
PNG_IMAGE_MESSAGE_BYTES =

Buffer size for libpng's per-image error message (PNG_IMAGE_MESSAGE_BYTES). The png_image struct stores a fixed-size char buffer of this length.

64
PNG_IMAGE_SIZE =

png_image struct field offsets (bytes). png_image is laid out as:

void*       opaque             (pointer-width)
png_uint_32 version            (uint32)
png_uint_32 width              (uint32)
png_uint_32 height             (uint32)
png_uint_32 format             (uint32)
png_uint_32 flags              (uint32)
png_uint_32 colormap_entries   (uint32)
png_uint_32 warning_or_error   (uint32)
char[64]    message

Total = pointer-size + 7*4 + 64. The fixed width makes it safe to allocate via FFI::MemoryPointer directly so the wrapper stays Ractor-safe (FFI::Struct has class-level state that isn't shareable across non-main Ractors).

FFI.type_size(:pointer) + (7 * 4) + PNG_IMAGE_MESSAGE_BYTES
PNG_IMAGE_OFF_OPAQUE =
0
PNG_IMAGE_OFF_VERSION =
FFI.type_size(:pointer)
PNG_IMAGE_OFF_WIDTH =
PNG_IMAGE_OFF_VERSION + 4
PNG_IMAGE_OFF_HEIGHT =
PNG_IMAGE_OFF_WIDTH + 4
PNG_IMAGE_OFF_FORMAT =
PNG_IMAGE_OFF_HEIGHT + 4
PNG_IMAGE_OFF_FLAGS =
PNG_IMAGE_OFF_FORMAT + 4
PNG_IMAGE_OFF_COLORMAP_ENTRIES =
PNG_IMAGE_OFF_FLAGS + 4
PNG_IMAGE_OFF_WARNING_OR_ERROR =
PNG_IMAGE_OFF_COLORMAP_ENTRIES + 4
PNG_IMAGE_OFF_MESSAGE =
PNG_IMAGE_OFF_WARNING_OR_ERROR + 4
LIBPNG_VERSION =

Gem version follows the pattern:

{LIBPNG_VERSION}.{LIBPNG_RUBY_ITERATION}

where LIBPNG_VERSION is the upstream libpng release this gem is built against, and LIBPNG_RUBY_ITERATION is a counter for Ruby-side changes (recipe bug fixes, CI changes, docs) that bump without a new libpng release. The iteration resets to 0 each time LIBPNG_VERSION bumps.

'1.6.58'
LIBPNG_RUBY_ITERATION =
2
VERSION =
"#{LIBPNG_VERSION}.#{LIBPNG_RUBY_ITERATION}"

Class Method Summary collapse

Class Method Details

.decode(png, pixel_format: 'RGBA') ⇒ Object

Decode a PNG file into raw pixels.

png String containing PNG file bytes pixel_format desired output format ("RGB", "RGBA", "GRAY", etc.) (default: "RGBA")

Returns a DecodedImage Struct with #width, #height, #format (String), and #pixels (binary String).

Ractor-safe: same reason as encode.



227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
# File 'lib/libpng.rb', line 227

def decode(png, pixel_format: 'RGBA')
  fmt = FORMAT_BY_NAME[pixel_format.to_s.upcase] ||
        raise(Error, "unknown pixel_format #{pixel_format.inspect}")

  img = FFI::MemoryPointer.new(:uint8, PNG_IMAGE_SIZE)
  img.clear
  img.put_uint32(PNG_IMAGE_OFF_VERSION, PNG_IMAGE_VERSION)

  begin
    FFI::MemoryPointer.new(:uint8, png.bytesize) do |in_buf|
      in_buf.write_bytes(png)
      ok = png_image_begin_read_from_memory(img, in_buf, png.bytesize)
      if ok.zero?
        msg = read_message(img)
        raise Error, "png_image_begin_read_from_memory failed: #{msg}"
      end

      img.put_uint32(PNG_IMAGE_OFF_FORMAT, fmt)
      width = img.get_uint32(PNG_IMAGE_OFF_WIDTH)
      height = img.get_uint32(PNG_IMAGE_OFF_HEIGHT)
      stride = width * bytes_per_pixel_for_format(fmt)
      out_size = stride * height

      FFI::MemoryPointer.new(:uint8, out_size) do |out_buf|
        ok = png_image_finish_read(img, nil, out_buf, stride, nil)
        if ok.zero?
          msg = read_message(img)
          raise Error, "png_image_finish_read failed: #{msg}"
        end
        return DecodedImage.new(width: width,
                                height: height,
                                format: pixel_format.to_s.upcase,
                                pixels: out_buf.read_bytes(out_size))
      end
    end
  ensure
    png_image_free(img)
  end
end

.encode(width, height, pixels, pixel_format: 'RGBA', convert_to_8bit: false, strip_colorspace: true) ⇒ Object

Encode raw pixel data as a PNG.

width, height image dimensions in pixels pixels String of raw pixel bytes (row-major, top-down) pixel_format "RGB", "RGBA", "GRAY", "GA", "BGR", "BGRA" (default: "RGBA") convert_to_8bit when true, libpng converts 16-bit input to 8-bit on write (default: false) strip_colorspace when true, removes any sRGB/gAMA/cHRM/iCCP chunks libpng's simplified API emits by default, leaving only IHDR, IDAT, and IEND. This matches the "classic" libpng write path (e.g. libemf2svg) and produces byte-identical output. Default: true.

Returns a String containing the PNG file bytes. Raises Libpng::Error on failure (bad dimensions, insufficient input bytes, invalid pixel_format, or libpng-internal error).

Ractor-safe: every call allocates and frees its own png_image; no shared mutable state.



209
210
211
212
213
214
215
# File 'lib/libpng.rb', line 209

def encode(width, height, pixels, pixel_format: 'RGBA',
           convert_to_8bit: false, strip_colorspace: true)
  raw = encode_ancillary(width, height, pixels,
                         pixel_format: pixel_format,
                         convert_to_8bit: convert_to_8bit)
  strip_colorspace ? strip_ancillary_chunks(raw) : raw
end

.encode_standard(width, height, pixels, pixel_format: 'RGBA', filter: :default, compression_level: 6) ⇒ Object

Encode raw pixels via libpng's standard write API (png_create_write_struct -> png_set_IHDR -> png_set_rows -> png_write_png(PNG_TRANSFORM_IDENTITY)). This matches the byte output of the "classic" libpng write path used by libemf2svg's rgb2png, and avoids the sRGB/gAMA chunks the simplified API injects by default.

width, height image dimensions in pixels pixels String of raw pixel bytes (row-major, top-down) pixel_format "RGB", "RGBA", "GRAY", "GA" (default: "RGBA") filter :default (adaptive), :none, :sub, :up, :avg, :paeth, :all, :adaptive (default: :default) compression_level zlib level 0-9 (default: 6 = Z_DEFAULT_COMPRESSION)

Output is accumulated in a Ruby String via png_set_write_fn -- no Tempfile, no disk I/O. The FFI write callback is held on a local Array so it isn't GC'd mid-call.

Errors are raised via a png_set_error_fn callback (set on png_create_write_struct). libpng expects error callbacks to longjmp; rb_raise from the FFI callback unwinds the Ruby stack instead, which works for our usage but means the png_struct is not cleanly torn down inside libpng. The ensure block calls png_destroy_write_struct to release the C state.

Ractor-safe: all state is per-call. FFI callbacks are not shareable across Ractors, but they're locals and stay scoped to one Ractor.

Raises:



295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/libpng.rb', line 295

def encode_standard(width, height, pixels, pixel_format: 'RGBA',
                    filter: :default, compression_level: 6)
  raise Error, 'width must be positive' unless width.positive?
  raise Error, 'height must be positive' unless height.positive?

  fmt = FORMAT_BY_NAME[pixel_format.to_s.upcase] ||
        raise(Error, "unknown pixel_format #{pixel_format.inspect}")
  color_type = COLOR_TYPE_BY_FORMAT[fmt] ||
               raise(Error, "encode_standard does not support #{pixel_format} " \
                            '(use GRAY, GA, RGB, or RGBA; byte-order variants ' \
                            'like BGRA/ARGB/ABGR are simplified-API only)')

  bytes_per_pixel = bytes_per_pixel_for_format(fmt)
  stride = width * bytes_per_pixel
  expected = stride * height
  raise Error, "pixels too short: expected #{expected}, got #{pixels.bytesize}" if pixels.bytesize < expected

  filter_sym = filter.to_sym
  unless FILTER_MASK_BY_NAME.key?(filter_sym)
    raise Error, "unknown filter #{filter.inspect} " \
                 '(expected :default, :adaptive, :none, :sub, :up, :avg, :paeth, or :all)'
  end
  filter_mask = FILTER_MASK_BY_NAME[filter_sym]
  raise Error, 'compression_level must be 0..9' unless (0..9).cover?(compression_level)

  output = String.new.force_encoding('ASCII-8BIT')

  # FFI::Function objects need a stable reference for the duration of
  # the libpng calls -- otherwise they can be GC'd before libpng
  # invokes them.
  callbacks = []
  write_cb = FFI::Function.new(:void, %i[pointer pointer size_t]) do |_, data, len|
    output << data.read_bytes(len)
  end
  error_cb = FFI::Function.new(:void, %i[pointer string]) do |_, msg|
    raise Error, "libpng: #{msg}"
  end
  callbacks << write_cb << error_cb

  png_ptr = FFI::Pointer.new(0)
  info_ptr = FFI::Pointer.new(0)
  begin
    png_ptr = png_create_write_struct(LIBPNG_VER_STRING_C, nil, error_cb, nil)
    raise Error, 'png_create_write_struct returned NULL' if png_ptr.null?

    info_ptr = png_create_info_struct(png_ptr)
    raise Error, 'png_create_info_struct returned NULL' if info_ptr.null?

    png_set_compression_level(png_ptr, compression_level)
    png_set_write_fn(png_ptr, nil, write_cb, nil)
    png_set_IHDR(png_ptr, info_ptr, width, height, 8, color_type,
                 INTERLACE_NONE, COMPRESSION_TYPE_DEFAULT, FILTER_TYPE_DEFAULT)
    png_set_filter(png_ptr, FILTER_HEURISTIC_DEFAULT, filter_mask) if filter_mask

    FFI::MemoryPointer.new(:uint8, pixels.bytesize) do |px|
      px.write_bytes(pixels)
      FFI::MemoryPointer.new(:pointer, height) do |rows|
        height.times { |y| rows.put_pointer(y * FFI.type_size(:pointer), px + (y * stride)) }
        png_set_rows(png_ptr, info_ptr, rows)
        png_write_png(png_ptr, info_ptr, TRANSFORM_IDENTITY, nil)
      end
    end

    output
  ensure
    unless png_ptr.null?
      FFI::MemoryPointer.new(:pointer) do |pp|
        pp.write_pointer(png_ptr)
        FFI::MemoryPointer.new(:pointer) do |ip|
          ip.write_pointer(info_ptr)
          png_destroy_write_struct(pp, ip)
        end
      end
    end
  end
end