image API reference Changelog View on GitHub

Image decoders and encoders in Zig with no C: PNG, JPEG, GIF, WebP, TIFF, JPEG XL, and more.

Wuffs showed that image decoders can read hostile files safely without giving up speed. This package reads Wuffs's image formats in Zig, adds three that Wuffs does not read (TIFF, JPEG XL, and ICO), and has an encoder for every one. Nothing allocates. A decoder reads the header, says how much work memory it needs in Config.work_len, and writes pixels into your buffer in any of 24 pixel formats.

Each decoder is tested against its reference library on the same files, and fuzzed under one rule: a hostile file gets an error, never a crash, a hang, an out-of-bounds access, or unbounded memory. Each format is a namespace (image.png, image.jpeg, ...) with a Decoder, an encode, and a workLen, and the calls have the same shape in every one.

The API reference documents every public symbol.

Getting started

Install

zig fetch --save https://baskingspot.dev/image/v0.1.0.tar.gz

Add the module in build.zig:

const image = b.dependency("image", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("image", image.module("image"));

image builds with Zig 0.16 only. It depends on compress for zlib, DEFLATE, and Brotli, which brings fastmem, and on nothing else.

Import it

const image = @import("image");

image does not collide with a std namespace. It does collide with the obvious local name: Zig rejects a local image that shadows the import, so name values after their role, such as photo or icon.

Read the examples

docs/examples/ is the code this guide shows, compiled against the public module and run by just test.

Decode a file

This decodes the first frame of any supported file to RGBA8:

pub fn decodeFile(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, path: []const u8) !Rgba {
    const file = try dir.openFile(io, path, .{});
    defer file.close(io);
    var buffer: [4096]u8 = undefined;
    var reader = file.reader(io, &buffer);

    // The seeker lets ICO and TIFF reach data behind the reader.
    var decoder: image.Decoder = try .initDetect(&reader.interface, .{ .seeker = .file(&reader) });
    const config = try decoder.decodeConfig();

    const len = image.PixelBuffer.len(.rgba8, config.width, config.height) orelse
        return error.ImageTooLarge;
    const pixels = try gpa.alloc(u8, len);
    errdefer gpa.free(pixels);
    const work = try gpa.alloc(u8, config.work_len);
    defer gpa.free(work);

    const dst = try image.PixelBuffer.init(pixels, .rgba8, config.width, config.height);
    _ = try decoder.decodeFrame(dst, work) orelse return error.NoFrames;
    return .{ .pixels = pixels, .width = config.width, .height = config.height };
}

The allocator is yours. The decoder never sees it: it asks for sizes, and you hand it slices.

Decoding

Every format's Decoder has the same calls, and image.Decoder has them too:

  1. init(reader, options) makes a decoder and reads nothing.
  2. decodeConfig() reads the header and returns a Config. Later calls return the same Config.
  3. decodeFrame(dst, work) writes the next frame into dst and returns its Frame, or null when there are no more frames.

Read the header

Config describes the image before any pixel is decoded:

FieldWhat it says
widththe canvas width, which every dst must have
heightthe canvas height
nativethe PixelFormat that holds the source without loss and is cheapest to write
frame_countthe number of frames, or null when the header does not say
loop_counthow many times an animation plays (0 is forever), or null for a still image
work_lenthe bytes of work memory decodeFrame needs
is_opaquewhether the header promises that every pixel is opaque
backgroundthe background color the file names, straight RGBA, or null
metadatawhat the decoder found before the first frame (see Metadata)

Ask for config.native when you want the source's samples unchanged, such as 16-bit PNG or float TIFF.

Size the buffers

decodeFrame takes two buffers, and both are yours:

Input streams through the decoder, so work memory holds what the format must keep, not the image. QOI, Netpbm, and WBMP need none. GIF needs about 68 KiB for any image. PNG needs the 64 KiB inflate window, 8 KiB of staging, and two rows. TIFF needs the page's strip or tile offsets and one row of a strip or tile for chunky data. JPEG XL needs the most: its work_len covers whole-frame planes, though large lossy and lossless frames decode a group row at a time and touch far less of it.

Decode the frames

A still image has one frame. An animation or a multi-page file has one per call, written into its region of the canvas:

while (try decoder.decodeFrameConfig()) |next| {
    // Restoring to the previous canvas needs a copy made before the
    // frame is drawn.
    if (next.disposal == .previous) @memcpy(saved, canvas);
    const frame = (try decoder.decodeFrame(dst, work)).?;
    shown += 1; // Show `canvas` for `frame.duration` flicks here.
    switch (frame.disposal) {
        .none => {},
        .background => {
            const region = dst.sub(frame.x, frame.y, frame.width, frame.height);
            for (0..region.height) |y| @memset(region.row(@intCast(y)), 0);
        },
        .previous => @memcpy(canvas, saved),
    }
}

The canvas must hold the previous frame between calls. The decoder blends: a Frame.blend of src_over means the frame was composited onto what the canvas held. Disposal is yours: Frame.disposal says what to do with the region before the next call, and decodeFrameConfig returns the next frame's region and disposal before anything is drawn, so you can save what previous restores. The first frame of a GIF, APNG, or WebP animation clears the canvas outside its region to transparent black.

Frame.duration counts flicks, image.flicks_per_second (705,600,000) to the second, which divides every common frame rate exactly. JPEG XL coalesces its frames, as libjxl does by default: each frame comes back whole, with no disposal for you to apply.

Set limits

DecodeOptions.limits caps the image before any work. A file over a limit fails in decodeConfig, before the decoder writes a pixel or asks for memory, so buffers sized from Config stay within what you allowed:

var reader: std.Io.Reader = .fixed(file);
var decoder: image.png.Decoder = .init(&reader, .{ .limits = .{ .max_pixels = 100 } });
try testing.expectError(error.ImageTooLarge, decoder.decodeConfig());
// Errors are sticky: every later call returns the same one.
try testing.expectError(error.ImageTooLarge, decoder.decodeConfig());
FieldDefaultError
max_width16,777,215ImageTooLarge
max_height16,777,215ImageTooLarge
max_pixels2^28, per frame and canvasImageTooLarge
max_frames65,536TooManyFrames
max_work1 GiBWorkTooLarge

Limits.none lifts them all. A format whose header does not count its frames checks max_frames as frames arrive.

Stream the input

A decoder pulls from a std.Io.Reader whose buffer holds at least image.min_buffer_len (64) bytes, and reads it forward. It never seeks on its own. A file read through a 4 KiB buffer decodes the same as one held in memory with std.Io.Reader.fixed.

Some formats store offsets that can point behind the reader. Those decoders take an image.Seeker in DecodeOptions.seeker and use it only for data behind their position:

FormatWithout a seeker
TIFFdata behind the reader is error.DataBehind, and most writers put the IFD after the pixels
ICO and CURan entry behind the reader is error.EntryBehind
WebPan animation frame whose alpha is another size than its ANMF chunk says is error.AlphaBehind
jxl.JpegReconstructorerror.NeedsSeeker

Seeker.fixed(&reader) serves a reader made by std.Io.Reader.fixed over the whole file, with no copy. Seeker.file(&file_reader) serves a std.Io.File.Reader whose byte 0 is the file's first byte. For any other source, fill in context and seek yourself.

Handle errors

Every decoder returns image.DecodeError plus its format's own causes:

ErrorCause
ReadFailedthe reader failed; its own error is in the reader
Truncatedthe input ended before the image did
BadSignaturethe input does not start with this format's signature
BadHeadera header field has a value the format does not allow
Unsupporteda feature the format allows that this decoder does not read
ImageTooLargeover max_width, max_height, or max_pixels
TooManyFramesover max_frames
WorkTooLargework_len would be over max_work
WorkTooSmallwork is shorter than work_len
BufferTooSmalldst is smaller than its dimensions and stride need
SizeMismatchdst is not the canvas size

Errors are sticky. After one, every call on that decoder returns it, so a loop that stops on the first error cannot read a half-parsed stream by mistake. A frame that failed partway may have written some of its pixels into dst.

Detection

Name a file's format

image.detect(prefix) returns the image.Format of a file from its first image.detect_prefix_len (18) bytes, or null. A shorter prefix is fine for a short file.

const prefix = "\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR";
try testing.expectEqual(image.Format.png, image.detect(prefix).?);
try testing.expectEqual(null, image.detect("plain text, not an image"));

// CUR files decode through the ICO namespace.
try testing.expectEqualStrings("ico", image.Format.cur.namespace());
try testing.expect(image.Decoder.supports(.jxl));

PNG, JPEG, GIF, WebP, TIFF, JPEG XL, and QOI match their signatures exactly. BMP, ICO, CUR, and Netpbm also need plausible header fields. TGA has no signature and WBMP a weak one, so they are tried last, on header fields alone, and can match a file that is neither. The decoder still rejects what it cannot parse.

Decode any format

image.Decoder is a tagged union over the format decoders with the same calls. initDetect(reader, options) peeks at the first bytes, leaves them in the reader, and picks the format; init(format, reader, options) takes one you already know.

var reader: std.Io.Reader = .fixed(file);
var decoder: image.Decoder = try .initDetect(&reader, .{});
try testing.expectEqual(image.Format.qoi, decoder.format());

const config = try decoder.decodeConfig();
var pixel: [4]u8 = undefined;
const dst = try image.PixelBuffer.init(&pixel, .rgba8, config.width, config.height);
_ = (try decoder.decodeFrame(dst, &.{})).?;

Input that matches no format is error.UnknownFormat. DecodeOptions reaches the format's decoder field by field; options only one format has, such as png.Decoder.Options.ignore_checksums, need that format's Decoder. Decoder.decodeFrameConfig forwards to the format's own where it has one (GIF, ICO, Netpbm, PNG, TIFF, WebP) and answers for a format whose header says it has one frame; otherwise, as for a JPEG XL animation, it is error.Unsupported.

Pixel formats

Pick a pixel format

Every PixelFormat is interleaved. Samples wider than a byte are in the machine's byte order, so an aligned 16-bit buffer can be viewed as []u16 and a float one as []f32.

FamilyFormats
8-bitgray8, gray_alpha8, rgb8, bgr8, rgba8, bgra8
8-bit premultipliedgray_alpha8_premul, rgba8_premul, bgra8_premul
16-bitgray16, gray_alpha16, rgb16, bgr16, rgba16, bgra16
16-bit premultipliedgray_alpha16_premul, rgba16_premul, bgra16_premul
32-bit floatgray_f32, rgb_f32, rgba_f32, rgba_f32_premul, nominally 0 to 1
CMYKcmyk8, cmyk16

CMYK samples are inverted inks, as Adobe's JPEGs store them and libjpeg-turbo returns them: the maximum is no ink, so all-zero bytes are black here as in every other format. TIFF's and PDF's ink amounts are the maximum minus these. PixelFormat has bytesPerPixel, channelCount, hasAlpha, isPremultiplied, and channels, which says where each channel sits.

A decoder writes whatever format you ask for. Asking for config.native skips conversion. PFM and float TIFF are native rgba_f32, as is JPEG XL past 16 bits a sample, and JPEG XL writes float destinations from its own floats, without an integer step.

Lay out a buffer

PixelBuffer is pixels, format, width, height, and stride, the bytes from one row's start to the next. init packs rows; initStride takes a wider stride, and sub is a region that shares the memory:

// A 4 by 2 RGBA8 canvas with 32 bytes between rows: room for 8 pixels,
// of which 4 are the image.
var memory: [64]u8 = @splat(0);
const canvas = try image.PixelBuffer.initStride(&memory, .rgba8, 4, 2, 32);
const right = canvas.sub(2, 0, 2, 2);

var red: [2 * 2 * 3]u8 = undefined;
for (0..4) |i| red[i * 3 ..][0..3].* = .{ 255, 0, 0 };
try image.convert.convertBuffer(right, try .init(&red, .rgb8, 2, 2));

Decoders write into a buffer and never keep it, and they check its fields before writing, since the fields are public.

Convert pixels

image.convert converts between every pair of formats. convert does one run of pixels, Converter picks the kernel once for many rows, and convertBuffer converts a whole PixelBuffer into another of the same size:

// Two straight-alpha pixels: half-transparent red, opaque green.
const src = [_]u8{ 255, 0, 0, 128, 0, 255, 0, 255 };
var dst: [8]u8 = undefined;
image.convert.convert(&dst, .bgra8_premul, &src, .rgba8, 2);
try testing.expectEqualSlices(u8, &.{ 0, 0, 128, 128, 0, 255, 0, 255 }, &dst);
// Pick the kernel once, then run it on each row.
const converter: image.convert.Converter = .init(.gray8, .rgb8);
const rows = [_][6]u8{ .{ 255, 255, 255, 0, 0, 0 }, .{ 255, 0, 0, 0, 0, 255 } };
var gray: [2][2]u8 = undefined;
for (rows, &gray) |row, *out| converter.run(out, &row, 2);

Swizzles, adding and dropping alpha, premultiplying, gray to color, and 8-bit, 16-bit, and float samples in the same layout have SIMD kernels; Converter.isFast says whether a pair has one. Every other pair goes through 16-bit RGBA, or float RGBA when a side is float, with the same results the kernels give. The integer rounding is Wuffs's pixel swizzler's:

Converter.run may convert in place when both formats have the same pixel size.

Encoding

Encode an image

Every namespace has the same three declarations: EncodeOptions, with every field defaulted; workLen(options, width, height), the work memory, or null when the image is too large for the format; and encode(writer, src, options, work), which writes the whole file:

const options: image.png.EncodeOptions = .{ .effort = .best };
const work = try gpa.alloc(u8, image.png.workLen(options, 64, 32) orelse return error.ImageTooLarge);
defer gpa.free(work);
var out: std.Io.Writer.Allocating = .init(gpa);
defer out.deinit();
try image.png.encode(&out.writer, src, options, work);

src is a PixelBuffer in any format, converted with image.convert to what the file stores. The shape holds across all twelve namespaces:

inline for (formats) |format| {
    const work = try gpa.alloc(u8, format.workLen(.{}, 32, 16).?);
    defer gpa.free(work);
    var out: std.Io.Writer.Allocating = .init(gpa);
    defer out.deinit();
    try format.encode(&out.writer, src, .{}, work);
}

Every encoder returns image.EncodeError plus its own causes: WriteFailed, WorkTooSmall, ImageTooLarge (past what the format can store), and BufferTooSmall.

Pick the options

NamespaceOptions and their defaults
pngeffort (.default); filter (chosen per row); reduce to the smallest color type and depth (on); max_depth; gamma, primaries, srgb, icc, cicp, mastering_display, content_light_level, exif, density
jpegquality (75); subsampling (.@"4:2:0"); progressive (off); optimize Huffman tables (off); exif, icc
gifquantizer (.wu), refine k-means passes (0), dither (.floyd_steinberg), interlace (off), loop_count (0, forever), crop frames to what changes (on)
webplossless: effort 0 to 9 (5), as cwebp's -z; icc, exif, xmp
webp.lossyquality 0 to 100 (75), on cwebp's scale; effort (.default); alpha_compression (on); icc, exif, xmp
tiffcompression (.lzw); predictor (on); effort for Deflate; palette when smaller (on); density; rows_per_strip (about 8 KiB strips)
jxleffort (.default)
bmpformat: .auto, .bgr24, .bgra32, or .indexed8
icocursor (off), hotspot, payload (.auto: PNG at 256×256, BMP below)
tgarle (on)
netpbmkind (PGM, PPM, or PAM from the source), maxval, pfm_endian
wbmp, qoinone, except QOI's transfer (.srgb)

Three efforts (.fast, .default, .best) trade speed for size in PNG, TIFF's Deflate, lossy WebP, and JPEG XL.

Encode animations and pages

CallWrites
png.encodeAnimation(w, width, height, frames, options, work)an APNG from png.AnimationFrames with region, duration, disposal, and blend; frame 0 is the whole canvas
gif.encodeAnimation(...)a GIF animation from gif.AnimationFrames, with the NETSCAPE2.0 loop count
tiff.encodePages(w, pages, options, work)one TIFF page per PixelBuffer
ico.encodeAll(w, images, options, work)an icon with one entry per ico.Image, such as its 16, 32, 48, and 256 pixel sizes

Durations are flicks, as in Frame, and PNG stores the nearest fraction APNG can hold.

Metadata

Read color and orientation

Decoders report metadata in Config.metadata and never apply it. The pixels are the file's pixels.

FieldFrom
orientationEXIF or the format's own field, as an image.Orientation named by where the first row and column belong
transfera named curve: .srgb, .linear, .bt709, .dci, .pq, .hlg
gammaa plain power law, times 100,000 (45455 is 1/2.2)
primariesCIE xy of red, green, blue, and white
cicpPNG's cICP code points, which take precedence over the ICC profile and the other color chunks
intensity_target, mastering_display, content_light_levelHDR luminance, in nits
densitypixels per inch or per meter, or only the aspect ratio
icc, exif, xmpa Blob: where the bytes are in the input, their length, and encoding (.raw, .zlib, or .brotli)
jpeg_reconstructionthe JPEG XL file can rebuild its original JPEG
gain_mapthe JPEG carries an HDR gain map, which is not decoded

To show a photo upright, rotate or flip by orientation yourself. image.exif.orientation(bytes) reads the tag from EXIF data you hold, and Orientation.toExif gives the 1 to 8 value.

Collect ICC, EXIF, and XMP

The decoder does not buffer metadata. It passes each blob's bytes to DecodeOptions.blob_sink as it reads past them, in pieces, and the sink copies what it wants:

fn write(context: *anyopaque, blob: image.Blob, at: u64, bytes: []const u8) void {
    const c: *ExifCollector = @ptrCast(@alignCast(context));
    if (blob.kind != .exif or blob.encoding != .raw) return;
    if (at + bytes.len > c.bytes.len) return;
    @memcpy(c.bytes[@intCast(at)..][0..bytes.len], bytes);
    c.len = @max(c.len, @as(usize, @intCast(at)) + bytes.len);
}

at is the piece's offset in the blob, so at == 0 starts a blob and blob.endsAt(at, bytes.len) is true on its last piece. A format that learns a blob's length only at its end, such as GIF, passes blob.len as null until then. A Blob in Config.metadata always has its length. encoding says when the bytes are compressed (PNG's iCCP is zlib, JPEG XL's brob boxes Brotli); compress decodes both.

Read what follows the frames

Some files put metadata after the pixels: a TGA's footer, a PNG eXIf after the image data, GIF and WebP chunks after the first frame, the boxes after a JPEG XL codestream. image.Decoder.finalConfig() returns the Config updated with them once decodeFrame has returned null, and null before that or after an error:

while (try decoder.decodeFrame(dst, work)) |_| {}
try testing.expectEqual(image.Orientation.right_top, decoder.finalConfig().?.metadata.orientation);

Format notes

PNG and APNG

JPEG

GIF

WebP

TIFF

JPEG XL

BMP, ICO, and CUR

TGA, Netpbm, WBMP, and QOI

Performance

Each format's best library is the bar, on throughput and on peak memory. These numbers come from just bench-compare on one core of a 2.6 GHz Intel Xeon (Ice Lake) cloud host in November 2026: image in ReleaseFast for the native CPU, the C libraries at -O3 -march=native. A ratio is image's throughput over the reference's, so above 1 is faster. Ranges are the geometric means of three size classes: icons up to 64×64, images up to a megapixel, and larger ones up to a 50-megapixel mosaic.

Format and operationAgainstResult
PNG decodeWuffs1.34–1.62×, at 240 MiB peak RSS over the rows against Wuffs's 384
PNG encodelibpng2.3–3.5× libpng's speed at .default, with files 1.3% smaller than libpng's and libspng's
JPEG decodelibjpeg-turbo3–5× on icons and 1.03–1.35× on photos past a megapixel, with the same pixels
JPEG encodelibjpeg-turbo2.16× over every row, 1.18× on baseline 4:2:0 past a megapixel, with the same bytes
GIF decodeWuffs1.12× over 52 rows, with the lowest peak RSS of every library measured
WebP decodelibwebplossless 2.14×, 1.15×, 1.11×; lossy 1.77×, 1.14×, 1.04×; animation 1.50× and 1.23×
WebP lossless encodelibwebp1.96× over every row, and smaller in total at every effort
WebP lossy encodelibwebp.default 1.30–1.44× cwebp -m 4, 2.7% smaller at equal PSNR and 4.4% at equal SSIM
TIFF decodelibtifficons 1.3–6.5×; past icons PackBits 1.6–1.75×, Group 4 1.2–1.5×, JPEG 1.1–1.3×, Deflate 1.06–1.16×, LZW 0.89–1.00×
TIFF encodelibtiffPackBits 1.5–2.8×, LZW 1.25–1.70×, Deflate below 0.8×, compress's DEFLATE against zlib
JPEG XL decodelibjxllossy about 0.9× on a 1200×1200 photo, both on one thread; 1.3–1.4× on icons
JPEG XL rebuildlibjxlJpegReconstructor 1.55× over every row
JPEG XL encodelibjxl.default writes about cjxl -e 7's sizes, 0.84× its speed on icons and faster above, at 131 MiB peak RSS against libjxl's 261
BMP decodeWuffs1.30× on icons, 0.97–0.99× above
TGA decodeWuffs1.68×, 1.14×, 1.00×; RLE 2.57× and 4.45×
QOI decodeWuffs1.64–1.83×
QOI encodeqoi.h1.5×, with the same bytes
Netpbm decodeWuffsno row slower

The measured losses: JPEG XL decode trails libjxl past icons, TIFF's LZW decode and Deflate encode trail libtiff, BMP decode sits at Wuffs's speed past icons, and GIF encode ran at 0.73× giflib's speed over all rows when first measured, where on the truecolor rows giflib times only its LZW writer against image's quantizer and ditherer. WebP lossless encode of flat, few-color images at efforts 1 to 6 runs at 0.39–0.66× libwebp. Every library here runs on one thread; libjxl can use more, and this package cannot.

bench/README.md describes the harness, the arms, and the method. just bench-compare reruns it on your machine; its numbers describe that machine on that day.

Limits