zenbitmaps [](https://github.com/imazen/zenbitmaps/actions/workflows/ci.yml) [](https://crates.io/crates/zenbitmaps) [](https://lib.rs/crates/zenbitmaps) [](https://docs.rs/zenbitmaps) [](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field) [](#license)

June 28, 2026 · View on GitHub

zenbitmaps is a pure-Rust decoder and encoder for the simple, lossless bitmap formats — PNM (PBM/PGM/PPM/PAM/PFM), farbfeld, BMP, QOI, TGA, and Radiance HDR. #![forbid(unsafe_code)], no_std + alloc, and panic-free, with cooperative cancellation and resource limits on every decode path. Built as ground-truth I/O for codec testing and apples-to-apples comparisons.

FormatFeatureDecodeEncodeDetection
PNM (PBM/PGM/PPM/PAM/PFM)(default)all 9 variantsP5/P6/P7/PFMP1-P7/Pf/PF magic
Farbfeld(default)farbfeld magic
BMPbmp1/2/4/8/16/24/32-bit, RLE, BITFIELDS24-bit / 32-bitBM magic
QOIqoiqoif magic
TGAtgaheader heuristic + v2 footer
Radiance HDRhdr#?RADIANCE / #?RGBE

PNM decode of maxval-255 input is zero-copy — a borrowed slice into your buffer, no allocation. Throughput methodology and a per-machine repro command: benchmarks/README.md.

Quick start

[dependencies]
zenbitmaps = "0.2"                                       # PNM + farbfeld (default)
# zenbitmaps = { version = "0.2", features = ["all"] }     # + BMP, QOI, TGA, HDR, SIMD, typed pixels
use zenbitmaps::*;
use enough::Unstoppable;

// Encode 2 RGB pixels to PPM (P6, maxval 255)
let pixels = vec![255u8, 0, 0, 0, 255, 0];
let encoded = encode_ppm(&pixels, 2, 1, PixelLayout::Rgb8, Unstoppable)?;

// Decode — auto-detects the format from its magic bytes
let decoded = decode(&encoded, Unstoppable)?;
assert!(decoded.is_borrowed());            // zero-copy for PPM with maxval 255
assert_eq!(decoded.pixels(), &pixels[..]);
# Ok::<(), zenbitmaps::At<zenbitmaps::BitmapError>>(())

Signatures & types. Dimensions are u32 everywhere (encode_*(.., width: u32, height: u32, layout: PixelLayout, stop: impl Stop); decode/decode_with_limits take &[u8]). On DecodeOutput<'a>, width / height (u32) and layout (PixelLayout) are public fields; pixels() -> &[u8], is_borrowed() -> bool, and into_owned() -> DecodeOutput<'static> are methods. PixelLayout and ImageFormat are #[non_exhaustive] enums — a match on either needs a wildcard (_ =>) arm.

encode_ppm contract. P6 is RGB-only and encode_ppm always writes maxval = 255 (8-bit). It accepts Rgb8 (verbatim), Bgr8/Rgba8/Bgra8 (swizzled to RGB; alpha dropped), and Gray8 (replicated to R=G=B). Any other layout — including the 16-bit/float ones (Gray16, Rgba16, GrayF32, RgbF32) and Bgrx8 — is rejected with BitmapError::UnsupportedVariant (it does not silently truncate or mis-encode). For 16-bit/float output use encode_pam (16-bit integer) or encode_pfm (float); encode_pgm is the grayscale analog (also 8-bit maxval = 255).

Output pixel layout (read decoded.layout)

decode() returns pixels in the source format's native layout — it does not normalize to RGBA8. Use decoded.pixels() for the packed bytes and decoded.layout (a [PixelLayout]) to learn what those bytes are:

Sourcedecoded.layout
BMPRgb8 (24-bit), Rgba8 (32-bit), or Gray8
PGM (P2/P5)Gray8 or Gray16
PPM (P3/P6)Rgb8 (16-bit PPM is downscaled to Rgb8)
PAM (P7)per DEPTHGray8/Gray16, Rgb8, or Rgba8 (16-bit RGB/RGBA are downscaled to 8-bit)
farbfeldalways Rgba16
PFMRgbF32 (PF) or GrayF32 (Pf) — top-down, native-endian f32 (see byte conventions)
QOIRgb8 or Rgba8

To force a specific layout (e.g. RGBA8), convert from decoded.layout with a pixel-conversion crate such as zenpixels-convert. Note as_pixels::<P>() reinterprets the existing bytes (and errors on a layout mismatch) — it is a zero-copy view, not a converter.

Byte conventions for float & 16-bit (read before rendering)

These are the details a server needs to avoid an upside-down or byte-swapped image. They are not obvious from the format names, so they are spelled out here. (decoded.pixels() always returns packed bytes in decoded.layout.)

PFM (GrayF32 / RgbF32):

  • Row order is normalized to top-down. PFM stores scanlines bottom-to-top on disk; the decoder reverses them so decoded.pixels() is top-left-origin like every other format here. You do not need to flip it — render row 0 at the top. (Encode does the inverse: encode_pfm writes your top-down buffer back out bottom-to-top.)
  • Samples are returned as native-endian f32. On disk PFM carries a signed scale whose sign selects byte order (negative = little-endian file, positive = big-endian file); the decoder reads accordingly and re-emits each sample as host-native f32 bytes. Reinterpret decoded.pixels() as &[f32] directly (or use the rgb feature's typed view) — no byte-swap needed.
  • The scale magnitude is applied to every sample, i.e. returned value = file_value * scale.abs(). The scale is consumed during decode and is not surfaced separately, so values are already in the file's intended units. A non-finite or zero scale is rejected (InvalidHeader).
  • encode_pfm always writes a scale of -1.0 (little-endian, unit scale).

16-bit integer (Gray16):

  • Samples are returned as native-endian u16, regardless of whether the source was binary (P5/P7) or ASCII (P2). PNM stores binary 16-bit samples big-endian on disk (most-significant-byte first); the decoder byte-swaps to host order so every path produces the same buffer for the same logical image. Reinterpret decoded.pixels() as &[u16] directly (or read each pair with u16::from_ne_bytes) — no per-format byte-swap needed. (16-bit P6/PPM and other 16-bit layouts are downscaled to 8-bit during decode, so this only concerns Gray16.)
  • encode_pam writes Gray16 back out big-endian (the PNM on-disk convention), converting from the native-endian in-memory buffer, so a decode → encode → decode round-trip is byte-lossless and the file is portable across hosts.

Format detection

detect_format() identifies the format from magic bytes without decoding:

use zenbitmaps::*;

match detect_format(&data) {
    Some(ImageFormat::Pnm) => { /* PGM, PPM, PAM, or PFM */ }
    Some(ImageFormat::Bmp) => { /* Windows bitmap */ }
    Some(ImageFormat::Farbfeld) => { /* farbfeld RGBA16 */ }
    Some(ImageFormat::Qoi) => { /* QOI */ }
    Some(ImageFormat::Hdr) => { /* Radiance HDR */ }
    Some(ImageFormat::Tga) => { /* TGA (Targa) */ }
    None => { /* unknown */ }
    _ => { /* future formats */ }
}

decode() uses this internally and dispatches to the right codec.

Supported formats

PNM family (always available):

  • P1 (PBM ASCII), P4 (PBM binary) — 1-bit black/white
  • P2 (PGM ASCII), P5 (PGM binary) — grayscale, 8-bit and 16-bit
  • P3 (PPM ASCII), P6 (PPM binary) — RGB, 8-bit and 16-bit
  • P7 (PAM) — arbitrary channels (grayscale, RGB, RGBA), 8-bit and 16-bit
  • PFM — floating-point grayscale and RGB (32-bit per channel)
  • Decode: all 9 variants. Encode: P5/P6/P7/PFM (binary)
  • Magic: P1-P7/Pf/PF

Farbfeld (always available):

  • RGBA 16-bit per channel, big-endian
  • Magic: farbfeld

BMP (bmp feature):

  • All standard bit depths: 1, 2, 4, 8, 16, 24, 32
  • Compression: uncompressed, RLE4, RLE8, BITFIELDS
  • Palette expansion, bottom-up/top-down, grayscale detection
  • BmpPermissiveness levels: Strict, Standard (default), Permissive
  • Native byte order decoding via decode_bmp_native() (skips BGR→RGB swizzle)
  • Magic: BM

QOI (qoi feature, vendored core from rapid-qoi):

  • RGB8 and RGBA8, lossless
  • Row-level streaming decode via decode_range
  • Streaming encode via push_rows/finish
  • Magic: qoif

TGA (tga feature):

  • Uncompressed and RLE-compressed (types 1-3, 9-11)
  • True color (15/16/24/32-bit), grayscale, color-mapped
  • All image origins (top/bottom, left/right)
  • Fast path: memcpy + SIMD batch BGR→RGB swizzle for 24/32-bit
  • Detection: header heuristic (TGA has no magic bytes)

Radiance HDR (hdr feature):

  • RGBE format with new-style per-channel RLE
  • Decodes to RgbF32 (linear float)
  • RGBE↔f32 via IEEE 754 bit manipulation (no libm, no unsafe)
  • Encodes from RgbF32 or Rgb8
  • Magic: #?RADIANCE / #?RGBE

Zero-copy decoding

PNM files with maxval=255 (the common case) decode to a borrowed slice into your input buffer. No allocation, no copy. Formats requiring transformation (BMP row flip, farbfeld endian swap, 16-bit, non-255 maxval, PFM) allocate.

With the rgb feature, as_pixels() gives you a zero-copy typed view:

let decoded = decode(&data, Unstoppable)?;
let pixels: &[RGB8] = decoded.as_pixels()?; // zero-copy reinterpret

With the imgref feature, as_imgref() gives you a zero-copy 2D view:

let decoded = decode(&data, Unstoppable)?;
let img: imgref::ImgRef<'_, RGB8> = decoded.as_imgref()?; // zero-copy 2D view

to_imgvec() is also available when you need an owned copy.

BGRA pipeline

BMP files store pixels in BGR/BGRA order. Use decode_bmp_native() to skip the BGR→RGB swizzle and work directly in native byte order:

let decoded = decode_bmp_native(&bmp_data, Unstoppable)?;
// decoded.layout is Bgr8, Bgra8, or Gray8

// All encoders accept BGR/BGRA input — swizzle happens automatically
let pam = encode_pam(decoded.pixels(), decoded.width, decoded.height,
                     decoded.layout, Unstoppable)?;

PGM, PPM, PAM, farbfeld, and BMP encoders all accept Bgr8, Bgra8, and Bgrx8 input.

Typed pixel API (rgb feature)

With the rgb feature, you get type-safe pixel encode/decode using the rgb crate's types:

use zenbitmaps::*;
use enough::Unstoppable;

let pixels = vec![RGB8 { r: 255, g: 0, b: 0 }, RGB8 { r: 0, g: 255, b: 0 }];
let encoded = encode_ppm_pixels(&pixels, 2, 1, Unstoppable)?;
let (decoded, w, h) = decode_pixels::<RGB8>(&encoded, Unstoppable)?;
# Ok::<(), zenbitmaps::At<zenbitmaps::BitmapError>>(())

Available types: RGB8, RGBA8, BGR8, BGRA8 (type aliases for rgb crate types).

ImgRef/ImgVec API (imgref feature)

With the imgref feature (implies rgb), you can work with 2D image buffers that handle stride/padding:

let img = imgref::ImgVec::new(pixels, width, height);
let encoded = encode_ppm_img(img.as_ref(), Unstoppable)?;
let decoded_img = decode_img::<RGB8>(&encoded, Unstoppable)?;

decode_into() decodes directly into a pre-allocated ImgRefMut buffer, handling arbitrary stride.

Cooperative cancellation

Every function takes a stop parameter implementing enough::Stop. Pass Unstoppable when you don't need cancellation. For server use, pass a token that checks a shutdown flag — decode/encode will bail out promptly via BitmapError::Cancelled.

The simplest constructible token is almost_enough::Stopper (cargo add almost-enough) — Clone, with all clones sharing one flag. stop is taken by value, so hand decode/encode a clone and keep one to cancel from a watchdog thread:

use zenbitmaps::decode;

let stopper = almost_enough::Stopper::new();
let watch = stopper.clone();   // hand a clone to a watchdog/deadline thread
// std::thread::spawn(move || { /* on shutdown */ watch.cancel(); });
let decoded = decode(&data, stopper)?;   // pass the token by value
// once cancelled, decode/encode returns Err(BitmapError::Cancelled(..)).

Resource limits

use zenbitmaps::*;
use enough::Unstoppable;

let limits = Limits {
    max_width: Some(4096),
    max_height: Some(4096),
    max_pixels: Some(16_000_000),
    max_memory_bytes: Some(64 * 1024 * 1024),
    ..Default::default()
};
# let data = encode_ppm(&[0u8; 3], 1, 1, PixelLayout::Rgb8, Unstoppable).unwrap();
let decoded = decode_with_limits(&data, &limits, Unstoppable)?;
# Ok::<(), zenbitmaps::At<zenbitmaps::BitmapError>>(())

Semantics:

  • All limit fields are Option<u64>. max_width/max_height are in pixels; max_pixels is width × height; max_memory_bytes is in bytes. A breach returns BitmapError::LimitExceeded. Dimension/pixel limits are checked against the header before allocating, so a crafted header is rejected cheaply.
  • max_memory_bytes caps the decoded output buffer — the post-expansion size (e.g. a 16-bit→8-bit PGM counts the 8-bit output; a PFM counts the f32 output: width × height × channels × 4). It does not cap the input slice.
  • There is always a default cap. Even plain decode() (no _with_limits) applies DEFAULT_MAX_MEMORY_BYTES (1 GiB) when you don't set max_memory_bytes, so a malicious header can't request an unbounded allocation. Set max_memory_bytes: Some(n) to raise or lower it for your workload.
  • The zero-copy borrowed path is not subject to max_memory_bytes (it allocates nothing — it returns a slice into your input), but it is still gated by max_width/max_height/max_pixels. So for untrusted input, set the dimension/pixel limits, not just the memory limit.
  • For untrusted input, prefer decode_with_limits with explicit max_width/max_height/max_pixels (and a max_memory_bytes tuned to your budget) rather than relying on the 1 GiB default — 1 GiB is a backstop against OOM, not a per-request size policy.

Errors (for a server)

decode*/encode* return At<BitmapError> (re-exported as zenbitmaps::At). The whereat::At wrapper carries the file:line (and a GitHub source link) where the error was raised, which is what you want in a server log or stack trace. The inner enum is BitmapError; call .error() on the At to borrow it (&BitmapError) and match on that. BitmapError is #[non_exhaustive], so keep a wildcard arm. Map it to an HTTP status like so:

use zenbitmaps::{decode, BitmapError};
use enough::Unstoppable;

let webp_or_bmp_bytes: &[u8] = &[];
let status = match decode(webp_or_bmp_bytes, Unstoppable) {
    Ok(_decoded) => 200,
    Err(e) => match e.error() {                        // e is At<BitmapError>; .error() -> &BitmapError
        BitmapError::DimensionsTooLarge { .. }
        | BitmapError::LimitExceeded(_) => 413,        // Payload Too Large
        BitmapError::UnrecognizedFormat
        | BitmapError::UnsupportedVariant(_) => 415,   // Unsupported Media Type
        // (with `--features zencodec`, also map `BitmapError::UnsupportedOperation(_) => 415`)
        BitmapError::Cancelled(_) => 499,              // client closed request
        // malformed input: InvalidHeader, InvalidData, UnexpectedEof, ...
        _ => 400,                                      // Bad Request
    },
};
# let _ = status;

The At itself Displays and Debugs with the location prefix, so logging e directly (tracing::error!("{e}")) records where it came from.

Features

FeatureWhat it adds
(default)PNM (P1-P7/PFM) + farbfeld decode/encode
bmpBMP decode/encode (all bit depths, RLE, bitfields, palettes)
qoiQOI decode/encode (vendored rapid-qoi core, streaming, lossless)
tgaTGA decode/encode (truecolor, grayscale, color-mapped, RLE)
hdrRadiance HDR decode/encode (RGBE, RLE, f32 output)
simdSIMD-accelerated BGR↔RGB swizzle via garb
rgbTyped pixel API (RGB8, RGBA8, as_pixels(), encode_*_pixels())
imgref2D buffer API (ImgVec/ImgRef, as_imgref(), decode_into()) — implies rgb
zencodeczencodec trait integration: streaming decode/encode, probe, CICP (implies rgb + imgref)
stdEnable std support (not required — no_std + alloc by default)
allAll format + pixel API features

API

All public functions are flat, one-shot calls at crate root.

Decode (auto-detect):

  • detect_format(data) — identify format from magic bytes
  • decode(data, stop) — auto-detect and decode
  • decode_with_limits(data, limits, stop)

Decode (format-specific):

  • decode_farbfeld / decode_farbfeld_with_limits
  • decode_bmp / decode_bmp_with_limits — RGB output (bmp)
  • decode_bmp_native / decode_bmp_native_with_limits — BGR output (bmp)
  • decode_bmp_permissive / ..._with_limits (bmp)
  • decode_qoi / decode_qoi_with_limits (qoi)
  • decode_tga / decode_tga_with_limits (tga)
  • decode_hdr / decode_hdr_with_limits (hdr)
  • probe_bmp(data) — BMP metadata without decode (bmp)

Encode (raw bytes):

  • encode_ppm, encode_pgm, encode_pam, encode_pfm — PNM family
  • encode_farbfeld — farbfeld
  • encode_bmp, encode_bmp_rgba — BMP (bmp)
  • encode_qoi — QOI (qoi)
  • encode_tga — TGA (tga)
  • encode_hdr — Radiance HDR (hdr)

Typed pixel (rgb): decode_pixels, encode_ppm_pixels, encode_pam_pixels, etc.

ImgRef/ImgVec (imgref): decode_img, decode_into, encode_ppm_img, etc.

Types:

  • DecodeOutput<'a> — decoded image (.pixels(), .width, .height, .layout, .is_borrowed(), .as_pixels(), .as_imgref(), .to_imgvec())
  • ImageFormat — format enum (Pnm, Bmp, Farbfeld, Qoi, Tga, Hdr)
  • PixelLayout — pixel format (Gray8, Gray16, Rgb8, Rgba8, Rgba16, Bgr8, Bgra8, Bgrx8, GrayF32, RgbF32)
  • BmpPermissiveness — decode strictness (Strict, Standard, Permissive) (bmp)
  • Limits — resource limits (max width/height/pixels/memory)
  • BitmapError — error enum, #[non_exhaustive]. The public error is At<BitmapError> — match on .error() (see Errors).
  • At<E>whereat location-tracking error wrapper (re-export); Result<T> = Result<T, At<BitmapError>> is the crate's result alias

Performance

benches/codecs.rs measures decode and encode throughput for all six codecs on a 1000×1000 image with zenbench: interleaved A/B timing, single-threaded, every buffer already in memory (no I/O in the timed region), output consumed via black_box. PNM decode of maxval-255 input is zero-copy (a borrowed slice — no allocation), so it reports memcpy-class numbers; TGA decode uses memcpy + a batch BGR↔RGB swizzle for 24/32-bit uncompressed images, and the simd feature accelerates that swizzle via garb on the TGA and QOI encode paths.

git clone https://github.com/imazen/zenbitmaps && cd zenbitmaps
cargo bench --bench codecs --all-features     # build WITHOUT -C target-cpu=native

Methodology, environment, and per-machine reproduction: benchmarks/README.md.

Credits

  • PNM: draws from zune-ppm by Caleb Etemesi (MIT/Apache-2.0/Zlib)
  • BMP: forked from zune-bmp 0.5.2 by Caleb Etemesi (MIT/Apache-2.0/Zlib)
  • Farbfeld: forked from zune-farbfeld 0.5.2 by Caleb Etemesi (MIT/Apache-2.0/Zlib)
  • QOI: vendored core from rapid-qoi by Zakarum (MIT/Apache-2.0)
  • TGA, HDR: from-scratch implementations, no external dependencies

AI-Generated Code Notice

Developed with Claude (Anthropic). Not all code manually reviewed. Review critical paths before production use.

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.

Image tech I maintain

Codecs ¹zenjpeg · zenpng · zenwebp · zengif · zenavif · zenjxl · zenbitmaps · heic · zentiff · zenpdf · zensvg · zenjp2 · zenraw · ultrahdr
Codec internalszenjxl-decoder · jxl-encoder · zenrav1e · rav1d-safe · zenavif-parse · zenavif-serialize
Compressionzenflate · zenzop · zenzstd
Processingzenresize · zenquant · zenblend · zenfilters · zensally · zentone
Pixels & colorzenpixels · zenpixels-convert · linear-srgb · garb
Pipeline & frameworkzenpipe · zencodec · zencodecs · zenlayout · zennode · zenwasm · zentract
Metricszensim · fast-ssim2 · butteraugli · zenmetrics · resamplescope-rs
Pickers & MLzenanalyze · zenpredict · zenpicker
ProductsImageflow image engine (.NET · Node · Go) · Imageflow Server · ImageResizer (C#)

¹ pure-Rust, #![forbid(unsafe_code)] codecs, as of 2026

General Rust awesomeness

zenbench · archmage · magetypes · enough · whereat · cargo-copter

Open source · @imazen · @lilith · lib.rs/~lilith