Ruby License

libpng is a Ruby binding for the official PNG reference library (libpng). The native shared library is pre-compiled for each target platform and shipped inside the gem, so gem install libpng Just Works without a C compiler on the host.

Installation

Add to your Gemfile:

gem "libpng"

Then:

bundle install

Or install manually:

gem install libpng

RubyGems automatically selects the pre-compiled gem that matches your platform. No C compiler, libpng development headers, or system packages are required at install time when a binary gem is available. The platform-agnostic ruby gem is the fallback: it downloads the libpng source tarball at install time and compiles it via CMake (requires cmake, ninja, and zlib headers).

Supported platforms

The following 11 pre-compiled platform gems are published for each release — 10 native targets plus the platform-agnostic source (ruby) gem (see the RubyGems versions page for the current set):

Gem platform Target Build host

ruby (source)

Any platform (compiles on install)

n/a

x86_64-linux

x86_64 Linux (glibc)

ubuntu-latest

x86_64-linux-musl

x86_64 Linux (musl, e.g. Alpine)

ruby:<ver>-alpine on x86_64

aarch64-linux

ARM64 Linux (glibc)

ubuntu-24.04-arm (native)

aarch64-linux-musl

ARM64 Linux (musl)

ruby:<ver>-alpine on arm64

aarch64-linux-ohos

ARM64 OpenHarmony / Huawei HarmonyOS PC

ubuntu-24.04-arm + OHOS NDK (cross-compiled, signed, verified in dockerharmony)

x64-mingw32

x64 Windows, RubyInstaller < 3.0 (MSVCRT)

windows-latest

x64-mingw-ucrt

x64 Windows, RubyInstaller >= 3.0 (UCRT)

windows-latest

aarch64-mingw-ucrt

ARM64 Windows (Ruby >= 3.4)

windows-11-arm (native)

x86_64-darwin

Intel macOS

macos-15-intel

arm64-darwin

Apple Silicon macOS

macos-latest

Usage

require "libpng"
# Encode raw RGBA pixels (row-major, top-down) as a PNG.
png = Libpng.encode(width, height, rgba_bytes, pixel_format: "RGBA")
File.binwrite("out.png", png)
# Decode a PNG into raw pixels.
decoded = Libpng.decode(File.binread("out.png"), pixel_format: "RGBA")
decoded.width       # => Integer
decoded.height      # => Integer
decoded.format      # => "RGBA"
decoded.pixels      # => binary String of RGBA bytes
decoded.bit_depth   # => 8 (from IHDR)
decoded.color_type  # => 6 (PNG_COLOR_TYPE_RGB_ALPHA, from IHDR)
decoded.interlace   # => 0 (PNG_INTERLACE_NONE, from IHDR)
decoded.text        # => {"Title" => "Sunset", "Author" => "Jane"} (tEXt/zTXt/iTXt)
decoded.color       # => {:gamma => 0.45455, :srgb_intent => 0, ...} (gAMA/cHRM/sRGB/iCCP)
decoded.phys        # => {:pixels_per_unit_x => 3779, :unit => 1, :dpi_x => 95.99, ...} (pHYs)

Supported pixel formats

  • "GRAY" / "GRAYSCALE" — 8-bit grayscale (1 byte/pixel)

  • "GA" / "AG" — 8-bit grayscale + alpha (2 bytes/pixel)

  • "RGB", "BGR" — 8-bit truecolor (3 bytes/pixel)

  • "RGBA", "ARGB", "BGRA", "ABGR" — 8-bit truecolor + alpha (4 bytes/pixel)

Encoding options

Libpng.encode(width, height, pixels,
              pixel_format: "RGBA",
              convert_to_8bit: false,
              strip_colorspace: true)
convert_to_8bit: true

causes libpng to convert 16-bit input to 8-bit on write (input is still passed as a packed byte buffer; use this if you have packed 16-bit-per-channel data).

strip_colorspace: true

(default) walks the PNG chunk list after encode and keeps only IHDR, IDAT, and IEND, dropping the sRGB/gAMA chunks libpng’s simplified API emits by default. This matches the "classic" libpng write path (e.g. libemf2svg) and produces byte-identical output. Set to false to keep the ancillary color chunks.

Standard API (encode_standard)

For parity with libpng’s classic write path — the same code path used by libemf2svg’s `rgb2png — use encode_standard instead of encode. It calls png_create_write_struct + png_set_IHDR
png_set_rows + png_write_png(PNG_TRANSFORM_IDENTITY) directly.

Libpng.encode_standard(width, height, pixels,
                       pixel_format: "RGBA",
                       filter: :default,
                       compression_level: 6,
                       interlace: :none,
                       bit_depth: 8,
                       palette: nil,
                       # Metadata writers (each optional):
                       text: { "Title" => "Sunset", "Author" => "Jane" },
                       gamma: 0.45455,
                       srgb_intent: 0,
                       chromaticities: { white_point_x: 0.3127, ... },
                       icc_profile: { name: "sRGB IEC61966-2.1", data: bytes },
                       phys: { pixels_per_unit_x: 3779, pixels_per_unit_y: 3779, unit: 1 })
pixel_format

:gray, :ga, :rgb, :rgba, or :palette. The byte-order variants (BGR, ARGB, BGRA, ABGR) are simplified-API only — the standard API always emits host-order.

filter

:default (adaptive — libpng picks the best filter per row, the libemf2svg default), :adaptive (same output, exercises a different libpng code path), :none, :sub, :up, :avg, :paeth, :all.

compression_level

zlib level 0-9. Default 6 (Z_DEFAULT_COMPRESSION, matching libemf2svg).

interlace

:none (default) or :adam7.

bit_depth

8 (default) or 16. Only valid for :gray, :rgb, :rgba. 16-bit callers must provide 2 bytes per channel (host-order little-endian). Always 8 for :palette.

palette

Array of [r, g, b] or [r, g, b, a] (1..256 entries). Required when pixel_format: :palette. If any entry has alpha, a tRNS chunk is emitted.

Output is written directly into 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 survives the libpng calls.

Differences from encode: - Emits only IHDR / IDAT / IEND (no post-hoc chunk stripping needed). - Supports palette, 16-bit, and interlaced output — simplified API doesn’t. - Errors are raised via a png_set_error_fn callback (set on png_create_write_struct). libpng expects error callbacks to longjmp; we rb_raise instead, which unwinds the Ruby stack. The ensure block calls png_destroy_write_struct to release the C state.

Standard API (decode_standard)

Libpng.decode_standard is the read-side counterpart to encode_standard. It calls png_create_read_struct
png_set_read_fn (memory reader) + png_read_info + transforms
png_read_image, giving the caller explicit control over which transforms apply (palette-to-RGB, gray-to-RGB, alpha add/strip, 16-to-8 demotion, interlace handling).

decoded = Libpng.decode_standard(png_bytes, pixel_format: "RGBA", bit_depth: 8)

Returns the same Libpng::DecodedImage shape as decode, so metadata fields (text, color, phys) work uniformly across both decode paths. Use decode for the common case (auto-transforms); use decode_standard when you need to know exactly which transforms applied (e.g. for parity with libemf2svg’s `png2rgb).

Ractor safety

Libpng.encode, Libpng.encode_standard, Libpng.decode, and Libpng.decode_standard are all Ractor-safe. Every call allocates and frees its own libpng state; no shared mutable state exists on the Ruby side. Calls from multiple Ractors run concurrently without locking.

Example (Ruby 3.x uses Ractor#take; Ruby 4.0+ uses Ractor#value):

r = Ractor.new do
  Libpng.encode(2, 2, "\xFF" * 16, pixel_format: "RGBA")
end
r.take   # Ruby 3.x
r.value  # Ruby 4.0+ (Ractor#take was removed in 4.0)

Versioning

This gem follows a {LIBPNG_VERSION}.{ITERATION} scheme:

  • LIBPNG_VERSION is the upstream libpng release the gem is built against (currently 1.6.58).

  • ITERATION is a counter that bumps on Ruby-side changes (recipe bug fixes, CI tweaks, doc updates). It resets to 0 each time LIBPNG_VERSION bumps.

For example, 1.6.58.0 is the first release against libpng 1.6.58, 1.6.58.1 is a Ruby-side fix, and 1.6.59.0 would be a release against the next upstream libpng.

How it works

The gem has three layers:

  1. lib/libpng.rb — Ruby FFI wrapper. Loads libpng16.{so,dylib,dll} from the gem’s lib/libpng/ directory at runtime and exposes Libpng.encode / Libpng.decode against libpng’s "simplified" high-level C API (png_image_*). The wrapper manually lays out the png_image struct via explicit byte offsets into an FFI::MemoryPointer rather than FFI::Struct, because FFI::Struct carries class-level state that is not shareable across non-main Ractors.

  2. lib/libpng/recipe.rb — a MiniPortileCMake recipe that downloads the libpng source tarball pinned by LIBPNG_URL / LIBPNG_SHA256, runs CMake with PNG_SHARED=ON PNG_STATIC=OFF PNG_TESTS=OFF, and copies the resulting shared lib into lib/libpng/. Invoked only when installing the source (ruby platform) gem.

  3. ext/extconf.rb — gem extension entry point. Calls Recipe#cook_if_not, which is a no-op once the per-platform checkpoint file (ports/…​/<name>-<ver>-<platform>.installed) exists. After cooking, emits a dummy Makefile via mkmf so RubyGems is satisfied (no Ruby C extension is compiled). The pre-compiled platform gems unset spec.extensions, so this never runs for them.

Pre-compiled platform gems

Rakefile defines gem:native:<platform> tasks for each target. Each task clones the gemspec, sets spec.platform, adds lib/libpng/*.{dll,so,dylib} to spec.files, removes the mini_portile2 dependency (no longer needed at install time), and unsets spec.extensions — the resulting gem installs with no compiler.

The release workflow (.github/workflows/release.yml) builds all 10 platforms in parallel on the appropriate native runner:

  • x86_64 Linux glibc — ubuntu-latest

  • ARM64 Linux glibc — ubuntu-24.04-arm (native)

  • x86_64 Linux musl — ruby:<ver>-alpine via docker run on ubuntu-latest

  • ARM64 Linux musl — ruby:<ver>-alpine via docker run on ubuntu-24.04-arm

  • x64 Windows (both MSVCRT and UCRT) — windows-latest with step-security/msvc-dev-cmd

  • ARM64 Windows — windows-11-arm (native) with arch: arm64

  • Intel macOS — macos-15-intel

  • Apple Silicon macOS — macos-latest

Linux glibc and macOS builds run natively on the matching runner. Linux musl builds run inside ruby:<ver>-alpine containers via docker run rather than the workflow container: field, because Actions' JS runtime has no arm64-musl build and therefore cannot run Alpine containers on arm64 runners. Windows builds use the Visual Studio toolchain via step-security/msvc-dev-cmd (a maintained drop-in for the deleted ilammy/msvc-dev-tools).

License

BSD-2-Clause; see LICENSE.txt. The gem bundles pre-compiled libpng binaries; libpng itself is distributed under the libpng license.