Skip to content

Architecture and Core Concepts

Pragmatic.Imaging is a .NET binding over a Rust native library built on the image crate. It exists to give server-side .NET code a small, AOT-friendly, predictable image pipeline without adopting a heavy managed dependency.


Server-side image processing in .NET typically means:

  • ImageSharp / SkiaSharp: capable, but larger dependency surface and non-trivial AOT / trimming story
  • System.Drawing.Common: dead end on non-Windows, no modern format support
  • Call out to ImageMagick / FFmpeg: works but adds a spawned-process failure domain

Pragmatic.Imaging takes a different shape: a small, purposeful API over a Rust core that handles decoding, encoding, and transformation for the formats servers actually care about.

  • Rust core: a native library compiled from the image crate plus a thin FFI wrapper. Decoding, encoding, and transformations happen native-side.
  • .NET binding: a small managed API that owns the native buffer, exposes a fluent pipeline, and maps errors to ImagingException.
  • Controlled native memory: decoded pixels live in native memory; the binding deals in pointers and lengths and copies to managed arrays only when it has to.
┌────────────────────────────────────────────┐
│ ImagePipeline (managed) │
│ ┌─────────────────────────────────────┐ │
│ │ native handle → RawImage (unmanaged)│ │
│ └─────────────────────────────────────┘ │
│ │
│ Resize → Crop → Grayscale → Encode… │
└────────────────────────────────────────────┘
│ LibraryImport P/Invoke
▼
┌────────────────────────────────────────────┐
│ Pragmatic.Imaging.Native.dll / .so / .dylib │
│ ┌─────────────────────────────────────┐ │
│ │ image crate + FFI shims │ │
│ └─────────────────────────────────────┘ │
└────────────────────────────────────────────┘

The native library file is named Pragmatic.Imaging.Native.dll on Windows and libPragmatic.Imaging.Native.so / .dylib on Linux / macOS: the name the P/Invoke asks for.


Every multi-step imaging operation goes through ImagePipeline. You load once, chain transformations, encode.

using var pipeline = ImagePipeline.Load(File.ReadAllBytes("input.jpg"));
var png = pipeline
.Resize(800, 600)
.Sharpen(sigma: 1.2f)
.Grayscale()
.Encode(ImageFormat.Png);

Each method returns the same ImagePipeline instance: it’s an in-place builder, not an immutable chain. You have one native buffer for the image, and every operation replaces it with the transformed result. This keeps native memory use predictable.

Encode/EncodeTo do not dispose the pipeline: you can encode the same pipeline state to several formats, or keep transforming afterwards. The native buffer is released only when you dispose the pipeline (via using); forgetting to dispose leaks native memory until the finaliser runs, so always use using or await using.

using var pipe = ImagePipeline.Load(bytes);
pipe.Thumbnail(400, 400);
var webp = pipe.Encode(ImageFormat.WebP); // WebP copy
var png = pipe.Encode(ImageFormat.Png); // …and a PNG copy; pipe still valid

PNG, JPEG, WebP, AVIF, GIF (first frame for static), BMP, TIFF.

PNG, JPEG, WebP, AVIF, GIF (first frame), BMP, TIFF: all decodable formats can also be encoded.

The quality argument (1–100) applies to JPEG only. WebP encodes lossless and AVIF uses the native library default: for both, quality is ignored (a limitation of image 0.25, which does not expose lossy quality control for these). PNG/BMP/TIFF/GIF are lossless or paletted and ignore quality too.


ImagePipeline.Load accepts a span, a byte[], or a Stream. It decodes the image immediately:

using var pipe = ImagePipeline.Load(byteArray);
using var pipe = ImagePipeline.Load(memory.Span); // zero-copy span overload
using var pipe = ImagePipeline.Load(fileStream); // reads the whole stream (bounded by MaxInputBytes)
  • the format is identified at load time (not lazily)
  • invalid input fails fast with ImagingException { Reason = DecodeFailed }
  • once loaded, transformations don’t pay the decode cost again

LoadAsync(ReadOnlyMemory<byte>, ct, options) and LoadAsync(Stream, ct, options) offload the CPU-bound decode to the thread pool.

  • Decoded pixels: width × height × 4 bytes of native memory per live pipeline, released on dispose.
  • Encoded input read from a Stream: Load(Stream), LoadAsync(Stream) and ImageInfo.FromStream read the encoded bytes into a buffer rented from ArrayPool<byte>.Shared and return it once decoded. A seekable stream sizes the buffer from its remaining length; otherwise the buffer starts at 80 KB and doubles, up to MaxInputBytes (or maxBytes for FromStream). So the input is held once, for the duration of the decode, and is not a fresh allocation each call.
  • Encoded output: EncodeTo(stream) and QrCode.GeneratePng(text, stream) write straight from the encoder’s native buffer, without a managed copy. Encode and GeneratePng(text) return a byte[], which is one copy of the encoded image. EncodeToStreamAsync goes through EncodeAsync, so it makes that copy too.

When you only need dimensions and format, use ImageInfo: it parses the header instead of decoding the whole image.

ImageInfo info = ImageInfo.FromBytes(bytes);
Console.WriteLine($"{info.Format} {info.Width}×{info.Height}");

ImageInfo is a readonly record struct(uint Width, uint Height, ImageFormat Format). ImageInfo.FromStream(stream, maxBytes: 65536) reads only enough bytes to identify the header, so you can cheaply validate user uploads without a full decode.


Production image endpoints need to resist pathological inputs: 50,000×50,000 “decompression bombs”, crafted headers, unexpected formats.

ImagingOptions caps what Load will accept. Limits are checked from the header, before the full decode:

var options = new ImagingOptions
{
MaxInputBytes = 20 * 1024 * 1024, // reject encoded input over 20 MB
MaxMegapixels = 25, // reject decoded images over 25 MP
MaxWidth = 8192, // 0 = unlimited
MaxHeight = 8192, // 0 = unlimited
AllowedFormats = [ImageFormat.Jpeg, ImageFormat.Png, ImageFormat.WebP], // null = any
};
using var pipe = ImagePipeline.Load(bytes, options);
Property Meaning Default
MaxInputBytes Encoded input size cap (bytes) 100 MB
MaxMegapixels Decoded megapixel cap 100 MP
MaxWidth / MaxHeight Per-dimension pixel caps (0 = unlimited) 0
AllowedFormats Fail-closed format allow-list (null = any) null

Three presets are provided: ImagingOptions.Default, ImagingOptions.Strict (20 MB / 25 MP, for user uploads), ImagingOptions.Relaxed (500 MB / 500 MP, for trusted batch work). The presets leave MaxWidth/MaxHeight/AllowedFormats unset; add them for untrusted input.

Violations throw ImagingException with a specific Reason (see the error model below).


Encode(format, quality) returns the encoded bytes; EncodeTo(stream, format, quality) writes them to a stream. EncodeAsync / EncodeToStreamAsync offload the work to the thread pool.

byte[] jpeg = pipe.Encode(ImageFormat.Jpeg, quality: 85);
pipe.EncodeTo(outStream, ImageFormat.WebP);
await pipe.EncodeToStreamAsync(response.Body, ImageFormat.Png, ct: ct);

Quality applies to JPEG only (see Formats).


ImageBatch is a static helper for processing many images with bounded concurrency:

byte[][] thumbs = await ImageBatch.ThumbnailsAsync(
images, // IReadOnlyList<byte[]>
maxWidth: 256, maxHeight: 256,
format: ImageFormat.WebP,
maxConcurrency: 4); // 0 → Environment.ProcessorCount / 2

ThumbnailsAsync / ConvertAsync cover the common cases; ProcessAsync(images, operation, maxConcurrency, ct) runs an arbitrary Func<ReadOnlyMemory<byte>, byte[]> per input. A single failed item surfaces as ImageBatchItemException (carrying the input Index); see operations.md for the exact failure semantics.


  • ImagePipeline: not thread-safe. One pipeline per thread/task.
  • ImageInfo.FromBytes, QrCode.GeneratePng, ImageConverter.*, ImageBatch.*: safe to call concurrently (each opens its own pipeline).

Sharing one ImagePipeline across threads corrupts its native state. The binding does not synchronise for you.


Imaging-pipeline failures surface as ImagingException, categorised by Reason (ImagingError):

try
{
using var pipe = ImagePipeline.Load(bytes, ImagingOptions.Strict);
}
catch (ImagingException ex) when (ex.Reason == ImagingError.MaxMegapixelsExceeded)
{
// handle decompression-bomb rejection distinctly
}

ImagingError values: NativeError, DecodeFailed, EncodeFailed, UnsupportedFormat, InputTooLarge, MaxWidthExceeded, MaxHeightExceeded, MaxMegapixelsExceeded, FormatNotAllowed.

Argument-shape mistakes throw ArgumentException, not ImagingException: a zero Resize dimension, an out-of-bounds Crop, a Rotate that isn’t 90/180/270, an empty QR string. Those are programming errors, distinct from runtime imaging failures.


The binding is AOT-compatible: no reflection, no dynamic code generation, LibraryImport-generated P/Invoke. The native library ships as a runtimes/{rid}/native/ asset in the NuGet.

See native-deployment.md for platform coverage and self-contained publish.