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
  • ASP.NET image endpoints — fine until you need to chain operations or stay allocation-controlled

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 pins the native handle, exposes a fluent pipeline, and maps errors to ImagingException.
  • Zero managed allocation on the hot path — the image bytes live in native memory; the binding deals only with pointers and size descriptors.
┌────────────────────────────────────────────┐
│ ImagePipeline (managed) │
│ ┌─────────────────────────────────────┐ │
│ │ native handle → RawImage (unmanaged)│ │
│ └─────────────────────────────────────┘ │
│ │
│ Resize → Crop → Grayscale → EncodeTo… │
└────────────────────────────────────────────┘
┌────────────────────────────────────────────┐
│ pragmatic_native.dll / .so / .dylib │
│ ┌─────────────────────────────────────┐ │
│ │ image crate + FFI shims │ │
│ └─────────────────────────────────────┘ │
└────────────────────────────────────────────┘

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

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

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

Disposing the pipeline (via using) frees the native handle. Forgetting to dispose leaks native memory until the finaliser runs — always use using or await using.


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

PNG, JPEG (with quality control), WebP (with quality control), BMP.

AVIF and TIFF are decode-only today. GIF output is not supported (use PNG or WebP instead).


ImagePipeline.Load accepts bytes or a stream. It immediately decodes the image:

  • the file format is identified at load time (not lazily)
  • invalid input fails fast with ImagingException
  • the pipeline is ready to use — transformations don’t pay decoding cost again
using var pipe = ImagePipeline.Load(byteArray);
using var pipe = ImagePipeline.Load(fileStream); // reads the whole stream
using var pipe = ImagePipeline.Load(memory.Span); // zero-copy span overload

For cases where you only need dimensions, use ImageInfo.FromBytes — it parses a format-specific header instead of decoding the whole image.

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

ImageInfo.FromStream(stream, maxBytes: 65536) only reads 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 corrupted headers.

ImagingOptions caps what the native side will accept:

var options = new ImagingOptions
{
MaxWidth = 8192,
MaxHeight = 8192,
MaxDecodedBytes = 200 * 1024 * 1024, // reject images that would exceed 200MB raw
AllowedFormats = ImageFormat.Png | ImageFormat.Jpeg | ImageFormat.WebP,
};
using var pipe = ImagePipeline.Load(bytes, options);

Violations throw ImagingException with a specific reason code. Always pass explicit options for untrusted input — the defaults are conservative but not bulletproof against adversarial input.


EncodeTo(stream, format, quality) serialises the current pipeline state to an output stream. Quality (0–100) only applies to lossy encoders (JPEG, WebP).

pipeline.EncodeTo(outStream, ImageFormat.Webp, quality: 80);
  • JPEG — standard 0-100 quality, ~85 is a good default
  • WebP — 0-100 quality, 75-85 is typical
  • PNG — quality argument ignored (PNG is lossless)

Encoding consumes the pipeline — after EncodeTo, the native buffer is released. To produce multiple encodings from the same source, load once and fork via the batch API or re-load.


For processing many images with the same operations, ImageBatch provides bounded concurrency:

var batch = new ImageBatch(maxConcurrency: 4);
await batch.ProcessAsync(inputPaths, async (input, ct) =>
{
using var pipe = ImagePipeline.Load(await File.ReadAllBytesAsync(input, ct));
pipe.Thumbnail(256, 256).EncodeTo(File.Create(input + ".thumb.webp"), ImageFormat.Webp);
});

ImageBatch caps how many pipelines are alive at once so you don’t exhaust native memory under load. Useful for on-demand thumbnailing endpoints and batch jobs.


  • ImagePipelinenot thread-safe. One pipeline per thread/task.
  • ImageInfo.FromBytes — thread-safe (pure function of input bytes).
  • QrCode.GeneratePng — thread-safe.

If you share a pipeline across threads, the Rust side will likely panic or corrupt state. The binding does not synchronise for you.


All errors surface as ImagingException:

try
{
using var pipe = ImagePipeline.Load(bytes);
pipe.Resize(999_999, 999_999); // violates ImagingOptions
}
catch (ImagingException ex) when (ex.Reason == ImagingError.MaxWidthExceeded)
{
// handle safety-limit violations distinctly
}

The Reason enum covers: DecodeFailed, EncodeFailed, UnsupportedFormat, MaxWidthExceeded, MaxHeightExceeded, MaxDecodedBytesExceeded, InvalidArgument, NativeError.


The binding is AOT-compatible. No reflection, no dynamic code generation. The native library ships as a runtimes/{rid}/native/pragmatic_native.dll|so|dylib asset in the NuGet.

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