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.
The Problem
Section titled “The Problem”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.
The approach
Section titled “The approach”- Rust core — a native library compiled from the
imagecrate 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 │ │ │ └─────────────────────────────────────┘ │ └────────────────────────────────────────────┘The pipeline model
Section titled “The pipeline model”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.
Formats
Section titled “Formats”Decoders (read)
Section titled “Decoders (read)”PNG, JPEG, WebP, AVIF, GIF (first frame for static), BMP, TIFF.
Encoders (write)
Section titled “Encoders (write)”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).
Loading
Section titled “Loading”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 streamusing var pipe = ImagePipeline.Load(memory.Span); // zero-copy span overloadInspecting without decoding
Section titled “Inspecting without decoding”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.
Safety limits
Section titled “Safety limits”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.
Encoding
Section titled “Encoding”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.
Batch processing
Section titled “Batch processing”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.
Thread safety
Section titled “Thread safety”ImagePipeline— not 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.
Error model
Section titled “Error model”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.
AOT and trimming
Section titled “AOT and trimming”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.
Related
Section titled “Related”- getting-started.md — minimal tutorials
- operations.md — full reference of transform / filter methods
- native-deployment.md — platform matrix and deployment notes