# oneimage docs, all pages (OneImage 1.10.1) Every page of https://recursiveai.org/oneimage/docs/ as markdown, getting started first. Index: https://recursiveai.org/oneimage/llms.txt # Getting started OneImage is an image library for .NET written in pure C#, with no dependency beyond .NET itself. This page goes from install to a first thumbnail, format conversion, editing, and serving resized images from ASP.NET Core. Every sample on it was compiled and run against the 1.10.1 packages from NuGet. ## Install OneImage targets .NET 10 (`net10.0`). ```sh dotnet add package RecursiveAI.OneImage ``` The package is `RecursiveAI.OneImage` and the namespace is `OneImage`. The package has no dependencies: no native binaries, no other NuGet packages. NativeAOT and trimming are supported. ## Your first thumbnail ```csharp using OneImage; // 400 px wide, upright, in sRGB, EXIF and GPS removed, written as WebP. Image.Thumbnail("in.jpg", 400).Save("thumb.webp"); ``` `Image.Thumbnail` does what a web-ready copy needs, in this order: it applies the EXIF orientation, resizes (a JPEG is decoded at 1/2, 1/4 or 1/8 scale when that is enough), converts to sRGB and strips EXIF, XMP and IPTC. It never makes an image larger than the original unless you pass `upscale: true`. It also takes a `Stream`, a `ReadOnlyMemory` or an existing `Image`. ## Convert format and quality ```csharp // The output format follows the file extension. Image.Load("in.jpg").Save("out.png"); // Quality and other codec settings go in SaveOptions. Image.Load("in.png").Save("out.jpg", new SaveOptions { EncoderOptions = new JpegEncoderOptions { Quality = 85 } }); Image.Load("in.png").Save("out.webp", new SaveOptions { EncoderOptions = new WebPEncoderOptions { Quality = 80 } }); // A stream has no extension, so name the format. using var stream = File.Create("out-stream.png"); Image.Load("in.jpg").Save(stream, PngFormat.Instance); ``` JPEG and WebP default to quality 75. Every format's options (PNG compression level, WebP lossless, GIF palette size, TIFF compression) are in the [guide](https://recursiveai.org/oneimage/docs/guide/#saving). The core package reads and writes PNG, JPEG, WebP, GIF, TIFF, BMP and ICO, and detects the format from the content, not the extension. ## Load, edit, save ```csharp Image photo = Image.Load("in.jpg"); // reads the headers only Console.WriteLine($"{photo.Width} x {photo.Height}"); // Fill a 200 x 200 box, cropping around the busiest part. photo.Resize(width: 200, height: 200, fit: Fit.Cover, anchor: Anchor.Smart) .Save("square.jpg"); // Each call returns a new Image; photo itself never changes. photo.Crop(0, 0, 1200, 800) .Rotate(90) .Flip(FlipAxis.Horizontal) .Save("edited.png"); ``` `Resize` takes a `Fit` (`Contain`, `Cover`, `Stretch`, `Pad`, `BoxPad`, `Min`) and an `Anchor`; leave out `width` or `height` to keep the aspect ratio. Crops, rotations, padding, color adjustments, blurs and filters, compositing and pixel access through a `Canvas` are in the [guide](https://recursiveai.org/oneimage/docs/guide/#operations). ## The lazy model An `Image` is a description of work, not a decoded bitmap: `Load` reads only the headers, each operation returns a new immutable `Image`, and pixels are computed tile by tile when you `Save`. So a file whose header is fine but whose data is damaged loads without error and throws at `Save`: put the `Save` inside your `try`, or load with `Validate = true`. ```csharp try { Image.Load("upload.jpg").Resize(width: 800).Save("upload.webp"); } catch (ImageException ex) // damaged data, a size limit, an unsupported operation { Console.WriteLine(ex.Message); } // To fail at Load instead, decode once up front. Image upload = Image.Load("upload.jpg", new LoadOptions { Validate = true }); ``` All of OneImage's own exceptions derive from `ImageException`. Because an image loaded from a path reopens the file when its pixels are needed, keep the file in place until you are done, or load from a `Stream` or `byte[]` instead ([sources and lifetime](https://recursiveai.org/oneimage/docs/guide/#sources-and-lifetime)). ## Serve images from ASP.NET Core `RecursiveAI.OneImage.Web` resizes, crops and converts images on request, driven by the query string, and caches the results on disk. It is a separate package, so the core package stays free of ASP.NET Core. ```sh dotnet add package RecursiveAI.OneImage.Web ``` ```csharp using OneImage.Web; var builder = WebApplication.CreateBuilder(args); builder.Services.AddOneImage(); var app = builder.Build(); app.UseOneImage(); // before UseStaticFiles app.UseStaticFiles(); app.Run(); ``` Every image under `wwwroot` can now be asked for in another size or format: ```text /photos/cat.jpg?width=400 400 px wide, same format, aspect ratio kept /photos/cat.jpg?w=300&h=300&fit=cover 300 x 300, cropped around the center /photos/cat.jpg?width=800&format=webp&q=80 WebP at quality 80 ``` A request without commands falls through to the static file middleware untouched, and an invalid value such as `width=abc` is a 400, never silently clamped. Requests for the same result are processed once and then served from the cache, with `ETag`, `Last-Modified` and 304 responses. On a public site, sign your URLs or allow only presets, because every distinct valid set of commands creates a cache entry. With a key set, a request without a valid `hmac` gets a 403. Here the key is at least 32 random bytes, base64, from configuration (for example the environment variable `Images__Key`): ```csharp using OneImage.Web; var builder = WebApplication.CreateBuilder(args); builder.Services.AddOneImage(o => { o.MaxWidth = 2000; o.Presets["thumb"] = "width=200&height=200&fit=cover&format=webp"; o.HmacSecretKey = Convert.FromBase64String(builder.Configuration["Images:Key"]!); }) .ConfigureCache(c => c.CacheRootPath = "/var/cache/images"); var app = builder.Build(); app.UseOneImage(); app.UseStaticFiles(); var signer = app.Services.GetRequiredService(); string src = signer.Sign("/photos/cat.jpg?preset=thumb"); // adds &hmac=... app.MapGet("/", () => Results.Text($"", "text/html")); app.Run(); ``` The defaults assume a public, unauthenticated endpoint: output is at most 4096 px a side and 16,777,216 pixels, sources at most 64 MiB, queries at most 1024 characters, at most one image per processor (at least two) is processed at a time, EXIF, XMP and IPTC are removed, and the cache folder sits outside `wwwroot`. The ImageSharp.Web query spellings (`rmode`, `ranchor`, `rsampler`, `bgcolor` and the rest) are accepted too. Commands, providers, hooks and the cache layout are in [OneImage.Web](https://recursiveai.org/oneimage/docs/web/). ## Add-on packages Drawing (paths, fills, strokes, brushes, clipping, boolean operations) and layered documents are in the core package. The rest comes as separate packages, so you only ship what you use: | Package | What it does | Install | |---|---|---| | [OneImage.Web](https://recursiveai.org/oneimage/docs/web/) | ASP.NET Core middleware: URL-driven resizing, cropping and conversion, a disk cache, signed URLs and request limits. | `dotnet add package RecursiveAI.OneImage.Web` | | [OneImage.Text](https://recursiveai.org/oneimage/docs/text/) | Fonts and text: OpenType shaping, right-to-left and complex scripts, line breaking, rich text and color glyphs. Depends on RecursiveAI.OneFont. | `dotnet add package RecursiveAI.OneImage.Text` | | [OneImage.Svg](https://recursiveai.org/oneimage/docs/svg/) | SVG and SVGZ as an image format, drawn at the size you ask for. Depends on OneImage.Text. | `dotnet add package RecursiveAI.OneImage.Svg` | | [OneImage.Psd](https://recursiveai.org/oneimage/docs/psd/) | PSD and PSB: the merged image as an image format, and the layers as a document you can edit and write back. | `dotnet add package RecursiveAI.OneImage.Psd` | | [OneImage.Pdf](https://recursiveai.org/oneimage/docs/pdf/) | PDF pages as an image format, each page a frame, drawn at the size you ask for. Depends on RecursiveAI.OnePdf. | `dotnet add package RecursiveAI.OneImage.Pdf` | SVG, PSD and PDF are not in the default configuration. Register the ones you use, then load them like any other file: ```csharp using OneImage; using OneImage.Pdf; using OneImage.Psd; using OneImage.Svg; var config = new ImageConfiguration() .Add(SvgFormat.Instance) .Add(PsdFormat.Instance) .Add(new PdfFormat(new PdfRenderOptions { Dpi = 150 })); var load = new LoadOptions { Configuration = config }; // SVG is drawn at the size you ask for, not scaled up. Image.Load("logo.svg", load).Resize(width: 400).Save("logo.png"); // PSD loads as its merged image. Image.Load("poster.psd", load).Resize(width: 800).Save("poster.webp"); // Each PDF page is a frame. Image.Load("report.pdf", load).Frames[0].ToImage().Save("page1.png"); ``` Text is drawn through the same vector drawing as shapes: ```csharp using OneImage; using OneImage.Drawing; using OneImage.Text; using Path = OneImage.Drawing.Path; var fonts = new FontCollection(); Font font = fonts.Add("DejaVuSans.ttf").CreateFont(48); Image.Load("in.jpg") .Fill(new Rgba32(255, 255, 255, 180), Path.RoundedRectangle(40, 40, 520, 120, 16)) .DrawText("Hello from OneImage", new TextOptions(font) { Origin = new(70, 115) }, Brush.Solid(new Rgba32(20, 20, 20, 255))) .Save("captioned.jpg"); ``` ## Where next - [Guide](https://recursiveai.org/oneimage/docs/guide/): the lazy model, errors, operations, `Canvas`, metadata, color, animation, limits, threading, AOT and trimming. - [OneImage.Web](https://recursiveai.org/oneimage/docs/web/): every command, the security defaults, signed URLs, caching and providers. - [Drawing](https://recursiveai.org/oneimage/docs/drawing/), [Text](https://recursiveai.org/oneimage/docs/text/) and [Layers](https://recursiveai.org/oneimage/docs/layers/). - [Migrating from ImageSharp and System.Drawing](https://recursiveai.org/oneimage/docs/migrating/), with code side by side, and [migrating from SkiaSharp](https://recursiveai.org/oneimage/articles/migrate-from-skiasharp/). - [Comparison](https://recursiveai.org/oneimage/docs/comparison/) with ImageSharp, SkiaSharp, Magick.NET and NetVips, and the [benchmarks](https://recursiveai.org/oneimage/docs/performance/). - [All docs](https://recursiveai.org/oneimage/docs/), or all of them as one Markdown file for an LLM: [llms-full.txt](https://recursiveai.org/oneimage/llms-full.txt). Guides: [compress images](https://recursiveai.org/oneimage/articles/compress-image-csharp/), [grayscale](https://recursiveai.org/oneimage/articles/convert-image-to-grayscale-csharp/), [watermarks](https://recursiveai.org/oneimage/articles/add-watermark-to-image-csharp/), [animated GIFs](https://recursiveai.org/oneimage/articles/resize-animated-gif-csharp/), [multipage TIFF](https://recursiveai.org/oneimage/articles/split-multipage-tiff-csharp/), [PDF to TIFF](https://recursiveai.org/oneimage/articles/pdf-to-tiff-csharp/), [PSD to PNG](https://recursiveai.org/oneimage/articles/psd-to-png-csharp/) and [resizing on the fly in ASP.NET Core](https://recursiveai.org/oneimage/articles/resize-images-on-the-fly-aspnet-core/). # OneImage guide Every snippet here is compiled and run by `tests/OneImage.Tests/Docs/DocsExamples.cs`. All types are in the `OneImage` namespace. Contents: [the lazy model](#the-lazy-model) · [errors](#errors) · [sources and lifetime](#sources-and-lifetime) · [saving](#saving) · [operations](#operations) · [pixel access and Canvas](#pixel-access-and-canvas) · [metadata](#metadata) · [color](#color) · [animation](#animation-and-multi-page-files) · [limits](#limits) · [threading and memory](#threading-and-memory) · [AOT and trimming](#aot-and-trimming) · [formats](#formats) ## The lazy model An `Image` is a description of a computation. `Image.Load` reads only the headers (size, format, metadata). Each operation returns a new `Image` that remembers what to do. Work happens when you pull pixels: `Save`, `ToArray()`, `CopyPixelsTo()`, or `ToCanvas()` followed by access. ```csharp Image img = Image.Load("photo.jpg"); // headers only Image small = img.Resize(width: 300); // nothing decoded yet small.Save("small.png"); // decode, resize and encode, streaming over tiles ``` What follows from this: - **Cheap pipelines.** Only what the output needs is computed. A crop never decodes the tiles outside it, and a JPEG thumbnail is decoded at 1/2, 1/4 or 1/8 scale when that is enough. Large images stream through bounded memory rather than being held whole. - **Images are immutable and safe to share between threads.** An operation never changes the image it is called on. - **Errors can appear late.** Corrupt pixel data is found when the pixels are pulled, which may be the `Save` at the end of the chain. See [Errors](#errors). - **Order of operations is yours.** `Resize` then `Crop` is a different image from `Crop` then `Resize`; the engine only reorders what cannot change the result (for example pushing a crop down to the decoder). - **Argument mistakes are eager.** A bad parameter (no width and no height, a negative size, an undefined enum) throws at the call that got it, not later. `Image` is not `IDisposable`: it holds no unmanaged resources. ## Errors Only these leave a public method: the library's own exceptions, `ArgumentException` and friends, I/O exceptions passed through unchanged, and `OperationCanceledException`. | Exception | Meaning | |---|---| | `ImageFormatException` | The data is not a recognized image or is damaged. | | `ImageLimitException` | The data is valid but exceeds an `ImageLimits` bound (see [Limits](#limits)). Raised from header values, before anything large is allocated. | | `ImageOperationException` | The request cannot be done: no encoder for the format, an unknown output extension, an op on a pixel format it does not support (for example `Composite` on a CMYK image). | | `ImageSourceChangedException` | A file loaded by path changed before its pixels were read (derives from `ImageOperationException`). | All four derive from `ImageException`. A missing or unreadable file throws the ordinary `FileNotFoundException`/`IOException`. ```csharp try { Image.Load("upload.png").Resize(width: 800).Save("out.jpg"); } catch (ImageException ex) // format, limit, operation, source-changed { Console.WriteLine(ex.Message); } ``` ### Errors can surface at `Save` `Load` validates the container (signature, header, limits) but does not decode pixels. A file whose header is fine and whose compressed data is damaged loads successfully, and throws `ImageFormatException` at the first pixel pull, typically in `Save`. A failed `Save(path)` deletes the partial output file. To find out up front, ask for validation: ```csharp // Decodes every frame once; throws ImageFormatException here if anything is wrong. Image img = Image.Load("upload.png", new LoadOptions { Validate = true }); ``` `Validate` costs a full decode. Frames that decode sequentially are kept, so the work is not repeated by the later `Save`. ### Probing without exceptions ```csharp if (Image.TryLoad("maybe.png", out Image? image)) { // use image } if (Image.TryIdentify(bytes, out ImageInfo? info)) { Console.WriteLine($"{info.Format.Name} {info.Width}x{info.Height}, {info.Frames.Count} frame(s)"); } ``` `TryLoad` and `TryIdentify` return `false` for `ImageFormatException` and `ImageLimitException`. I/O errors still throw. `Image.Identify` reads only the headers; it reports the *stored* size, before auto-orientation. ## Sources and lifetime | Source | What `Load` does | What you may do afterwards | |---|---|---| | Path | Reads the headers and records the file's size and modification time. The file is **reopened** whenever pixels are pulled. | Don't change or delete the file while the image is in use. If its size or modification time differ at pull time, `ImageSourceChangedException` is thrown. | | `Stream` | Copies the remaining bytes into pooled memory. | The stream is yours: close or dispose it right away. | | `ReadOnlyMemory` | References the memory, no copy. | Don't modify the memory while the image, or anything derived from it, is in use. | ```csharp Image fromPath = Image.Load("a.jpg"); Image fromStream = Image.Load(File.OpenRead("a.jpg")); // copied now Image fromMemory = Image.Load(File.ReadAllBytes("a.jpg")); // referenced ``` Async variants exist for I/O: `LoadAsync`, `IdentifyAsync`, `SaveAsync`. Everything that computes takes a `CancellationToken`. `Canvas` is `IDisposable`: dispose it to return pooled tile memory. Forgetting is safe, because the garbage collector reclaims the buffers, but slower. ## Saving `Save(path)` picks the format from the extension. A stream or `IBufferWriter` needs the format: `PngFormat.Instance`, `JpegFormat.Instance`, `GifFormat.Instance`, `BmpFormat.Instance`, `IcoFormat.Instance`, `TiffFormat.Instance`, or `WebPFormat.Instance`. Codec settings go in `SaveOptions.EncoderOptions`: ```csharp img.Save("q.jpg", new SaveOptions { EncoderOptions = new JpegEncoderOptions { Quality = 85, Subsampling = JpegSubsampling.Yuv444 } }); img.Save("q.webp", new SaveOptions { EncoderOptions = new WebPEncoderOptions { Quality = 80 } }); img.Save("l.webp", new SaveOptions { EncoderOptions = new WebPEncoderOptions { Lossless = true } }); img.Save("q.png", new SaveOptions { EncoderOptions = new PngEncoderOptions { CompressionLevel = 9 } }); img.Save("q.tif", new SaveOptions { EncoderOptions = new TiffEncoderOptions { Compression = TiffCompression.Deflate } }); img.Save("p.gif", new SaveOptions { EncoderOptions = new GifEncoderOptions { MaxColors = 64, Dither = Dither.Ordered } }); ``` Defaults: JPEG quality 75 (the libjpeg scale), 4:2:0, optimized Huffman tables on, sequential. WebP lossy quality 75, effort 4. Each options class documents the rest. Rules worth knowing: - Formats that cannot hold animation (JPEG, BMP) write the first frame only. PNG (APNG), WebP, GIF and TIFF (pages) write all frames. - JPEG has no alpha. An image with alpha is written with its stored color values; use `Flatten` to put it on a background first: ```csharp logo.Flatten(new Rgba32(255, 255, 255, 255)).Save("logo.jpg"); ``` - The encoder converts the pixel format when the format cannot hold it (for example 16-bit to 8-bit for JPEG). - Encoders produce identical bytes for identical input on a given platform, regardless of thread count. ## Operations All are lazy and return a new `Image`; all apply to every frame of an animation. XML docs on each method state the defaults and edge behavior. **Resize and thumbnails** ```csharp img.Resize(width: 300); // height follows the aspect ratio img.Resize(width: 200, height: 200, fit: Fit.Cover, anchor: Anchor.Smart); // fill the box, crop the overflow img.Resize(width: 200, height: 200, fit: Fit.Pad, background: new Rgba32(255, 255, 255, 255)); img.Resize(width: 100, filter: ResamplingFilter.Mitchell); ``` - `Fit`: `Contain` (default; fits inside the box), `Cover` (fills the box, crops), `Stretch`, `Pad`, `BoxPad` (like `Pad`, but never enlarges) and `Min` (covers the box without cropping, never enlarges). `ResizeInto(canvasWidth, canvasHeight, target)` scales the image into a given rectangle of a canvas. `Anchor`: nine positions plus `Smart`, an entropy/edge heuristic that picks the crop window. - Default filter `Auto`: Lanczos3 on an axis that shrinks, Catmull-Rom on one that grows. Others: `Nearest`, `Box`, `Bilinear`, `CatmullRom`, `Mitchell`, `Lanczos2`, `Lanczos3`, `Lanczos5`, `Lanczos8`, `Hermite`, `Robidoux`, `RobidouxSharp`, `Spline`, `Welch`. - Resampling runs in linear light on premultiplied alpha and treats untagged RGB as sRGB. Pass `linear: false` to filter the stored values. - **Upscale defaults differ on purpose.** `Resize` enlarges by default (`upscale: true`): you asked for that size. `Image.Thumbnail` never enlarges unless you pass `upscale: true`: a thumbnail is not bigger than the original. With `Resize(..., upscale: false)` Contain, Cover and Pad stop at scale 1. **Many small resizes: `PixelResizer`** `Image.Resize` builds a graph, which is the right tool for one image. A program that resizes thousands of small ones in a loop (text-line crops for OCR, sprites, tiles) pays for that graph and for the tap tables of every new size on each call. `PixelResizer` does the same resize straight from a span to a span and keeps its working memory between calls: ```csharp using var resizer = new PixelResizer(); // one per thread; reuse it for every call var buffer = new Gray8[4000 * 32]; foreach ((Gray8[] pixels, int width, int height) in crops) { (int w, int h) = PixelResizer.GetSize(width, height, height: 32); // the size Image.Resize(height: 32) gives resizer.Resize(pixels, width, height, buffer.AsSpan(0, w * h), w, h); } ``` - **Same bytes as `Image.Resize`.** The output is identical to `image.Resize(w, h, Fit.Stretch, filter: ..., linear: ...)` for every filter, with the same linear-light and alpha handling, box pre-reduce and rounding, on every hardware path and at any degree. `GetSize` returns the size `Resize` scales to before any crop or padding (for `Fit.Cover` and `Fit.Pad`, crop or pad the result yourself). - **No allocation after warm-up.** Buffers grow to the largest size seen and are reused; calls with a different source or target size each time allocate nothing either. `ScratchBytes` reports what is held, `maxScratchBytes` (default 256 MiB) bounds it (an over-large call throws `ImageLimitException` before allocating), and `Trim()`/`Dispose()` give it back to the array pool. - **Typed and raw.** `Resize` takes `Gray8`, `Gray16`, `Rgb24`, `Rgba32` and the other pixel structs (straight alpha), with optional strides in pixels. `ResizeRaw` takes bytes and a `PixelFormat`, which also covers premultiplied alpha, CMYK and Lab. - **Threads.** An instance is not thread-safe (an overlapping call throws `InvalidOperationException`); use one per thread. Large enough calls split their output rows over up to `maxDegree` threads (default `ImageEngine.DefaultCallDegree`); small ones, and `maxDegree: 1`, stay on the calling thread. The bytes never depend on it. `Image.Thumbnail(path, width, height?, fit, anchor, upscale, filter, keepMetadata, ...)` is `Resize` plus the right defaults for a web-ready copy, in this order: apply EXIF orientation; resize (shrink-on-load for JPEG, never larger than the original unless `upscale: true`); convert RGB to sRGB and embed the compact sRGB profile; strip EXIF/XMP/IPTC. It also accepts a `Stream`, `ReadOnlyMemory` or an existing `Image`. **Geometry** ```csharp img.Crop(10, 10, 100, 100); // x, y, width, height; must lie inside the image img.Rotate(90); // clockwise; multiples of 90 are exact pixel moves img.Rotate(15, background: new Rgba32(255, 255, 255, 255)); // expands to the bounding box by default img.Flip(FlipAxis.Horizontal); img.Pad(left: 10, top: 10, right: 10, bottom: 10, mode: EdgeMode.Mirror); img.Trim(); // remove a uniform border (eager: it looks at the pixels) ``` `Orient(1..8)`, `AutoOrient()` and `Affine(...)` are also there. `Perspective(...)` applies a 3×3 projective transform, given as a `ProjectiveMatrix`, a `ProjectiveTransformBuilder` (tapers, rotation, scale, skew, translation) or four corner points: ```csharp img.Perspective(new ProjectiveTransformBuilder().AppendTaper(TaperSide.Top, TaperCorner.Both, 0.7)); img.Perspective([(30, 10), (150, 40), (120, 130), (10, 100)]); // corners: top-left, top-right, bottom-right, bottom-left ``` Like `Rotate` and `Affine` it grows the canvas to the transformed bounding box by default. `Trim` is the one geometry op that computes immediately, because it must scan for the border. **Color and tone**: `Grayscale`, `BrightnessContrast`, `Exposure`, `Gamma`, `Levels`, `Curves`, `HueSaturationLightness`, `Invert`, `Threshold`, `Posterize`, `ChannelMixer`, `CombineChannels`, `AddAlpha`, `RemoveAlpha`, `Flatten`, `ConvertTo(PixelFormat)`. Adjacent tone ops fuse into one pass. Identity parameters return the input unchanged. Tone ops act on straight (not premultiplied) color and leave alpha alone; most work on encoded values, while `Grayscale` and `Exposure` default to linear light. **Color matrix and presets**: `ApplyColorMatrix(ColorMatrix)` multiplies straight RGBA by a 5×4 matrix (ImageSharp's layout; `ColorMatrix` documents it), and the looks built on it are `Sepia(amount)`, `BlackWhite()`, `Kodachrome()`, `Lomograph()`, `Polaroid()`, `ColorBlindness(mode)` (the eight simulations) and `Opacity(amount)`. They work on stored values, fuse with the other color ops, and the identity (`Sepia(0)`, `Opacity(1)`) returns the input. ```csharp Image warm = photo.Sepia(0.6f).BrightnessContrast(contrast: 0.1).Opacity(0.9f); Image seen = photo.ColorBlindness(ColorBlindnessMode.Protanopia); ``` ```csharp img.Grayscale().BrightnessContrast(brightness: 0.1, contrast: 0.2).Gamma(1.1); ``` **Palettes**: `Quantize(colors, dither, alpha)` limits an image to a palette of at most 256 colors but keeps an ordinary RGB(A) format, so you can still process it or save it anywhere. ```csharp img.Quantize(colors: 16, dither: Dither.FloydSteinberg); ``` **Compositing**: `Composite(overlay, x, y, opacity, linear)` lays one image over another (source-over, color-managed, premultiplied). It is the building block for watermarks: ```csharp Image marked = photo.Composite(mark, x: photo.Width - 90, y: photo.Height - 40, opacity: 0.8f); ``` The overlay is converted to the base image's profile. The blend happens on stored (encoded) values unless `linear: true`. `blend:` picks how colors combine (`BlendMode`: Normal, Multiply, Screen, Overlay, Darken, Lighten, ColorDodge, ColorBurn, HardLight, SoftLight, Difference, Exclusion, Add, Subtract, LinearBurn, LinearDodge, VividLight, LinearLight, PinLight, HardMix, Divide, Hue, Saturation, Color, Luminosity), following the W3C Compositing and Blending formulas. `alpha:` picks the Porter-Duff operator for coverage (`AlphaComposition`: SrcOver by default, then Clear, Src, Dest, DestOver, SrcIn, DestIn, SrcOut, DestOut, SrcAtop, DestAtop, Xor, Plus). Only pixels under the overlay change. ```csharp Image shaded = photo.Composite(texture, opacity: 0.6f, blend: BlendMode.Multiply); Image cutout = photo.AddAlpha().Composite(mask, alpha: AlphaComposition.DestIn); // the base needs an alpha channel for DestIn ``` **Filters** ```csharp img.GaussianBlur(sigma: 2); // sigmaY: for a different vertical sigma img.BoxBlur(radius: 3); img.Sharpen(amount: 1.5, radius: 1.2, threshold: 0.02, linear: false); // unsharp mask img.Median(radius: 1); // 3x3 median, removes speckle noise img.Sobel(); // edge strength as a gray image img.Convolve(new float[,] { { 0, -1, 0 }, { -1, 5, -1 }, { 0, -1, 0 } }); // your own kernel ``` All are lazy, read only their tile plus a margin of the kernel radius (so they stream), and apply to every frame. - **Working space.** `GaussianBlur`, `BoxBlur`, `Sharpen` and `Convolve` work in linear light on premultiplied alpha by default (`linear: true`), so blurring on transparency leaves no dark halos. `Sharpen` in linear light gives stronger halos on the dark side of edges; pass `linear: false` for the conventional look. `Median` and `Sobel` use the stored values and have no `linear` parameter. - **Edges.** `edge:` is `EdgeMode.Clamp` (default, repeat the edge pixel), `Mirror`, or `Constant` (transparent black, so edges darken on opaque images). `Wrap` is not supported by filters. - **Parameters.** `GaussianBlur(sigma)` has kernel radius `ceil(3 sigma)`; sigma 0 on an axis leaves it unchanged. `BoxBlur` radius is 1 to 4096 (window `2r+1`). `Sharpen`: `out = in + amount * (in - blur)`; `radius` is the Gaussian sigma; differences below `threshold` (0 to 1, fraction of full scale) are left alone; alpha is not sharpened. `Median` radius is 1 to 127. `Sobel(scale)` returns a gray image without alpha (luminance by Rec. 709 weights, divided so a black-to-white step gives full scale at `scale: 1`); it refuses CMYK and Lab. - **`Convolve`** applies the kernel as written (correlation, not flipped), needs odd width and height, divides by the sum of the weights unless you pass `divisor:` (1 when the sum is 0), then adds `offset:` as a fraction of full scale. A rank-one kernel runs as two 1-D passes, with the same result. - **Large blurs are approximated.** Gaussian sigma above 16 and box radius above 48 are computed on a reduced-resolution copy and enlarged, to keep streaming memory bounded. The result stays within a few 8-bit levels of the exact blur on photographs; see the XML docs for the measured difference. Below those limits the result is exact. - Results do not depend on the thread count. **Effects** ```csharp img.Pixelate(size: 8); // mosaic; each block takes its center pixel img.OilPaint(levels: 10, brushSize: 15); // painterly flattening img.Vignette(new Rgba32(0, 0, 0, 255)); // darken towards the corners; radiusX:, radiusY:, area: are optional img.Glow(new Rgba32(255, 255, 255, 255), radius: 200); // a radial tint, full color at the center img.BokehBlur(radius: 32, components: 2, gamma: 3); // lens blur; bright points bloom into discs img.GaussianSharpen(sigma: 3); img.DetectEdges(EdgeDetector.Kirsch); // also Prewitt, Scharr, RobertsCross, Kayyali, Laplacian3x3/5x5, LaplacianOfGaussian, Robinson, Sobel img.DetectEdges(EdgeDetector.Scharr, grayscale: false); // per color channel, keeps format and alpha img.EntropyCrop(threshold: 0.5f); // crop to the area with detail (eager, like Trim) ``` They are lazy (except `EntropyCrop`), read only their tile plus a margin (the radius, brush half-width or kernel size; none for `Pixelate`, `Vignette` and `Glow`), and apply to every frame. - **Working space.** These work on stored values (not linear light), as ImageSharp does; `GaussianSharpen(linear: true)` is the one that can switch. `OilPaint`, `Vignette`, `Glow` and `DetectEdges` accept gray and RGB images and refuse CMYK and Lab; `Pixelate` takes any format. - **Edges.** `BokehBlur`, `GaussianSharpen` and `DetectEdges` take `edge:` as the other filters do (clamp by default). - **Limits.** `BokehBlur` radius is 1 to 255 with 1 to 6 components (cost grows with the radius, not its square); `GaussianSharpen` sigma up to 42; `OilPaint` brush up to 255 and up to 1024 levels. - **`Vignette` and `Glow`** blend the color over the pixel (source-over, its alpha times the falloff). The vignette falloff is `0.9 * distance / sqrt(rx^2 + ry^2)`; the glow's is `1 - 0.95 * distance / radius`. Pass `area:` to confine the effect to a rectangle and center it there. The default glow color is black, as in ImageSharp. - Results do not depend on the thread count. **Histograms, thresholds and dithering** ```csharp img.AutoLevel(); // stretch the luminance histogram; syncChannels: false curves each channel img.AdaptiveThreshold(); // black and white against the mean of the surrounding window img.BinaryDither(Dither.Bayer8x8); // two colors, any of the dithers; upper:, lower: set the colors img.Dither(KnownPalettes.WebSafe, Dither.FloydSteinberg); // your own palette; scale: weakens it; fastMatch: false matches every pixel exactly img.Quantize(64, Dither.Atkinson, quantizer: Quantizer.Octree); // Wu (default), Octree, WebSafe or Werner ``` They are lazy and apply to every frame. `AutoLevel` counts the 8-bit luminance (Rec. 709) histogram exactly, so results do not depend on the thread count. `AdaptiveThreshold` reads a margin of `width / 16` pixels around each tile. Ordered dithers run per tile; error diffusion runs once, in row order, when the first tile is requested, and gives the same pixels whatever the thread count. **Histogram equalization** ```csharp img.HistogramEqualization(); // global; 256 levels, no clipping img.HistogramEqualization(HistogramEqualizationMethod.AdaptiveTileInterpolation, clipHistogram: true); // local contrast on an 8 x 8 grid, clipped at 350 img.HistogramEqualization(HistogramEqualizationMethod.AdaptiveSlidingWindow, tiles: 16); // a window around every pixel; slower img.HistogramEqualization(luminanceLevels: 65536); // 16-bit images at full precision ``` It works on gray and RGB images, is lazy, and applies to every frame. The luminance (Rec. 709 weights) of each pixel is mapped through the cumulative histogram; **an RGB pixel becomes gray**, since the new luminance is written to all three channels (this is what ImageSharp does too, and the output agrees with it within 1 level at 8 bits). Alpha is carried over. The global and tile methods count the whole image once before the first tile comes out; the sliding window needs only the neighborhood of each tile, but its cost per pixel grows with the window size (`width / tiles`). Clipping (`clipHistogram`, `clipLimit`) cuts bins above the limit and spreads the excess evenly in whole counts; the limit is an absolute count per bin, so it should scale with tile size. The XML docs describe the tile geometry, window edges and rounding in full. Results do not depend on the thread count. **Analysis** ```csharp ImageHistogram hist = img.Histogram(); // 256 bins per channel long darkRed = hist[0, 0]; // channel 0 (R), bin 0 ReadOnlySpan green = hist.Channel(1); ImageStatistics stats = img.Statistics(); ChannelStatistics red = stats.Channels[0]; // Count, Min, Max, Mean, StdDev ``` Both compute the whole frame immediately, in parallel, and only read pixels (the image is not changed). They look at **frame 0**; use `img.Frames[i].Histogram()` / `.Statistics()` for another frame. Channels are in stored order with alpha last (gray; R, G, B; C, M, Y, K). Samples are the stored values: no color conversion, no un-premultiplying. `bins` is 1 to 65,536; 8-bit value `v` goes to bin `floor(v * bins / 256)`. `Statistics` values are in the channel's stored units (0..255, 0..65,535 or 0..1: see `FullScale`); `StdDev` is the population standard deviation. `skipTransparent: true` ignores pixels with alpha 0. Lab images are refused. Corrupt data throws `ImageFormatException` here, like any pixel pull. ## Pixel access and Canvas Read pixels out of any `Image` as the struct you want. Formats are converted if they differ. ```csharp Rgba32[] all = img.ToArray(); // frame 0 var block = new Gray8[16 * 16]; img.CopyPixelsTo(block, new ImageRect(100, 100, 16, 16)); // a rectangle Image fromMemory = Image.FromPixels(pixels, width, height); // the other direction ``` Pixel structs: `Gray8`, `Gray16`, `GrayFloat`, `GrayAlpha8`, `Rgb24`, `Rgba32`, `Rgb48`, `Rgba64`, `RgbaHalf`, `RgbFloat`, `RgbaFloat`, `Cmyk32`, `LabFloat`. `Image.Format` is a runtime `PixelFormat` (`Rgb8`, `Rgba8`, `Gray8`, ...). For editing, use a `Canvas`: a mutable image stored in 128×128 tiles created on first write. ```csharp using Canvas canvas = img.ToCanvas(); // tiles are computed on first touch canvas[0, 0] = new Rgba32(255, 0, 0, 255); // single pixel, converted to/from the canvas format // Row access. T must match the canvas format exactly: a JPEG decodes to Rgb8, so T is Rgb24. using (PixelLock l = canvas.Lock(new ImageRect(10, 10, 50, 50), LockAccess.ReadWrite)) { for (int y = 0; y < l.Height; y++) { l.GetRow(y).Fill(new Rgb24(0, 0, 0)); } } Image before = canvas.Snapshot(); // O(1), immutable canvas[0, 0] = new Rgba32(0, 255, 0, 255); // the snapshot does not change ``` - `Lock` never converts. With a mismatched `T` it throws an `ArgumentException` that names the canvas format and the matching struct; to read in another pixel type use `canvas.CopyTo(...)` or `canvas.Snapshot().ToArray()`. - `Lock` access modes: `Read`, `Write` (you overwrite every pixel in the rectangle) and `ReadWrite`. Dispose the lock to commit. - `new Canvas(width, height, PixelFormat.Rgba8)` makes an empty (transparent black) canvas. - A canvas has one writer. Hand `Snapshot()` images to other threads. Snapshots share tiles and a tile is copied only when the canvas next writes it, so keeping many snapshots for undo is cheap. - A canvas holds a single frame. `ToCanvas()` uses frame 0. ## Metadata `Image.Metadata` carries `IccProfile`, `Exif`, `Xmp` and `Iptc` as raw blobs, plus `Orientation` (1–8). - **Preserved by default.** `Load` → ops → `Save` keeps the ICC profile and the EXIF/XMP/IPTC blobs for formats that can carry them. On save, the EXIF block's pixel-dimension and orientation tags are corrected to the saved image. A malformed EXIF block is dropped instead of written with stale values. - **Typed EXIF.** `image.Metadata.ReadExif()` (or `TryReadExif`) parses the common tags: `Orientation`, `Make`, `Model`, `CaptureTime`, `Gps`. ```csharp ExifData? exif = img.Metadata.ReadExif(); string? make = exif?.Make; ``` - **Auto-orient at load.** By default `Load` applies the stored EXIF orientation lazily: `Width`/`Height` are the upright size and `Metadata.Orientation` reads 1, so saving writes upright pixels and an orientation tag of 1. Opt out for the stored layout and the original tag: ```csharp Image stored = Image.Load("photo.jpg", new LoadOptions { AutoOrient = false }); ``` - **Strip it.** `StripMetadata()` removes EXIF, XMP and IPTC and the ICC profile; `StripMetadata(keepIcc: true)` keeps the profile. It leaves the orientation value alone, since that describes how to display the pixels. - **`Thumbnail` strips for privacy.** EXIF can contain GPS position and device details, so `Image.Thumbnail` removes EXIF/XMP/IPTC and keeps only the ICC profile (converted to sRGB) and the corrected orientation. `keepMetadata: true` carries the blobs over, GPS included; use it knowingly. ## Color - **Default: source space kept.** Decoding does not change color values. The embedded ICC profile travels with the image and is written back on save. `ConvertColor` and `AssignProfile` are explicit. - **`Thumbnail` converts to sRGB** (relative colorimetric, black point compensation), so wide-gamut and CMYK sources look right everywhere. - **Explicit conversion.** ```csharp Image p3 = img.AssignProfile(ColorProfile.DisplayP3); // label only: values are unchanged Image srgb = p3.ConvertColor(ColorProfile.Srgb); // converts values and embeds the profile Image perceptual = p3.ConvertColor(ColorProfile.Srgb, RenderingIntent.Perceptual, blackPointCompensation: false); ``` `ConvertColor` defaults to relative colorimetric with black point compensation on. Built-in profiles: `Srgb`, `DisplayP3`, `WideGamutRgb` (Adobe RGB (1998)-compatible), `ProPhoto`, `Rec2020`, linear variants, gray, `LabD50` and `GenericCmyk`; `ColorProfile.FromIcc(bytes)` reads any ICC v2/v4 matrix or LUT profile. DeviceLink, named-color and iccMAX profiles are not supported. - **Untagged CMYK** (a CMYK JPEG or TIFF with no profile) is interpreted with the built-in generic CMYK profile, derived from published FOGRA39 characterization data, when converting to RGB. See `THIRD-PARTY-NOTICES.md`. - **Linear light.** Resizing, rotation and compositing with `linear: true` convert to linear light first, treating untagged RGB and gray as sRGB; tone ops and blends use encoded values unless told otherwise. Each op documents its `linear` parameter. - HDR transfer functions (PQ/HLG) are not interpreted; `cICP` and similar tags are not applied. ## Animation and multi-page files An `Image` has `FrameCount`, `Frames`, `LoopCount` (0 = forever) and a `Delay` per frame. GIF, APNG, animated WebP and multi-page TIFF load as multiple frames. Operations apply to every frame, so animation survives a pipeline: ```csharp Image anim = Image.Load("spin.gif"); Console.WriteLine($"{anim.FrameCount} frames, loop {anim.LoopCount}, first delay {anim.Frames[0].Delay}"); anim.Resize(width: 60).Save("spin.webp"); // all frames, delays and loop count are kept Image still = anim.Frames[1].ToImage(); // one frame as its own image anim.Save("first.jpg"); // JPEG cannot animate: frame 0 only ``` Animations are exposed as fully composited frames of the canvas size (disposal and blending already applied). Members of `Image` that read pixels (`ToArray`, `CopyPixelsTo`, `ToCanvas`, `Histogram`, `Statistics`) use frame 0 only and ignore the rest, without warning; use `Frames[i]` (an `ImageFrame` has `ToArray`, `CopyPixelsTo`, `Histogram`, `Statistics` and `ToImage()`) for the others. Each TIFF page is a frame with its own size. ## Limits Everything read from untrusted bytes is checked against `ImageLimits` using header values, before the allocation they guard. | Limit | Default | |---|---| | `MaxPixelsPerFrame` | 2²⁸ | | `MaxDimension` | 65,535 | | `MaxFrames` | 1,000 | | `MaxTotalPixels` | 2³⁰ | | `MaxMetadataBytes` | 16 MB | | `MaxExpansionRatio` (inflate/LZW output vs. input) | 1,100 | ```csharp // Per call. Image.Load("a.jpg", new LoadOptions { Limits = new ImageLimits { MaxPixelsPerFrame = 1_000_000, MaxDimension = 4096 } }); Image.Load("a.jpg", new LoadOptions { Limits = ImageLimits.Unrestricted }); // trusted input only // Global default, set once at startup. ImageConfiguration.Default.Limits = new ImageLimits { MaxPixelsPerFrame = 50_000_000 }; ``` `ImageLimits.Unrestricted` removes the configurable bounds. Two engine caps remain: 2²⁴ pixels per dimension and 2²² tiles per frame. Exceeding a limit throws `ImageLimitException`. `Canvas` and `Save` take limits too. ## Threading and memory - Rendering uses the .NET thread pool with 128×128 tiles as the unit of work. A single call uses at most `ImageEngine.DefaultCallDegree` threads (default `min(4, cores)`), so one request does not take a whole server. Override per call with `SaveOptions.MaxDegree` / `LoadOptions.MaxDegree` / the `maxDegree` parameter; `ImageEngine.MaxDegree` (default: core count) caps everything. Results never depend on the degree. - Computed tiles are cached under a global byte budget, 256 MiB by default (`ImageEngine.CacheBudgetBytes`), least recently used first; an evicted tile is recomputed on demand. Canvas tiles are owned by the canvas and are not counted or evicted. ```csharp ImageEngine.DefaultCallDegree = 2; ImageEngine.CacheBudgetBytes = 64L * 1024 * 1024; ``` - Large images from sequential decoders (16 MiB of pixel data and up) stream through a window of tile rows instead of being held whole. An operation that needs rows out of order (rotate by 90° or 270°, a vertical flip) makes the engine decode the whole frame once. - **Region reads of large JPEG and PNG files.** Such a frame streams through a window of rows, so `CopyPixelsTo` of a rectangle behind the window would have to decode again. The first region read keeps only the window. From the second on, the rows read are kept in a plane in the pixel format you ask for (`Gray8`, `Rgb24`, ...), filled from the top down only as far as needed, so reading 44 bands or 20 random rectangles costs about one decode plus the copies, even when the image is rotated by its EXIF orientation. The plane is capped by `ImageEngine.RegionCacheBytes` (default 64 MiB per frame, 0 turns it off); a frame whose plane does not fit behaves as before. The trade-off is memory held while the `Image` lives: about 8.7 MB for a Gray8 plane of an A4 page at 300 dpi and 26 MB for Rgb24 (one plane per format used, least recently used dropped). Call `image.ReleaseDecodedPixels()` when you are done reading regions; later reads decode again. Pixels read never depend on the cache. To read bands without keeping the plane, pass `useRegionCache: false` (`image.CopyPixelsTo(buffer, rect, useRegionCache: false)`): the read is served from the decoder's sliding window, so top-down bands decode the page once and hold a few tile rows, while a rect that goes back above the window, or a rotated image, decodes again for that call (more time, no retained memory). It does not touch the process-wide `RegionCacheBytes`, and a plane another caller already built is still used. `ReleaseDecodedPixels()` (on `Image`, `ImageFrame` and any image made from the frame, `frame.ToImage()` included) gives back all of it: the plane, the window and decoder thread, a decode kept as tiles, decoder block caches and the cached tiles of every operation above the decode; the image keeps working and recomputes the same pixels. Small frames (under 16 MiB in their own format) are not streamed: the second read of one keeps the decoded frame as tiles, a full copy of the frame, until you release it. Buffers the library returned to its pools (the 48 MiB tile pool, and the shared byte-array pool the TIFF decoder uses) stay resident for reuse and are not held by the image. - `Image.Load(Stream)` copies the compressed file into pooled memory, once per image; `Image.Load(path)` and `Load(ReadOnlyMemory)` do not. - `Image` instances are safe for concurrent reads. A `Canvas` is not. ## AOT and trimming The library has no reflection, no dynamic code and no dependencies, and is annotated trimmable and AOT-compatible. Codecs are registered explicitly, so an app that only handles two formats can leave the others out of the published binary: build your own `ImageConfiguration` instead of touching `ImageConfiguration.Default` (which references every codec). ```csharp var config = new ImageConfiguration().Add(JpegFormat.Instance).Add(PngFormat.Instance); Image img = Image.Load("a.jpg", new LoadOptions { Configuration = config }); img.Save("a.png", new SaveOptions { Configuration = config }); // extension lookup uses this configuration // Image.Load("a.webp", new LoadOptions { Configuration = config }) throws ImageFormatException: WebP is not registered. ``` Format-less entry points (`Thumbnail`, `Load` without options) use `ImageConfiguration.Default`; pass `LoadOptions` with your configuration when trimming matters. You can add your own `IImageFormat` the same way. ## Formats | Format | Read | Write | |---|---|---| | PNG | all color types, depths 1–16, Adam7, tRNS, APNG | 8/16-bit gray, gray+alpha, RGB, RGBA; automatic or required palette (`PngColorMode`); APNG; levels 0–9; filter choice | | JPEG | baseline, extended and progressive, Huffman or arithmetic, 8- and 12-bit, gray/YCbCr/RGB/CMYK/YCCK, restart markers; DCT scaling 1/2, 1/4, 1/8. Not lossless or hierarchical | baseline or progressive; quality 1–100; 4:2:0/4:2:2/4:4:0/4:4:4; optimized tables; gray, RGB and CMYK | | WebP | lossy (VP8), lossless (VP8L), alpha, animation | lossy, lossless, alpha, animation | | GIF | GIF87a/89a, animation, transparency, disposal, loop count | global/local palettes, ≤256 colors, dithering (none, Floyd–Steinberg, ordered), frame differencing | | TIFF | strips and tiles, BigTIFF, none/LZW/Deflate/PackBits/JPEG/CCITT G3 and G4, predictors, planar, 1–16-bit and 32-bit float; gray/RGB/palette/CMYK; multi-page. Signed and 64-bit samples, plain YCbCr and CIELab are rejected with an error | none/LZW/Deflate/PackBits, 8/16/32f, multi-page, BigTIFF | | BMP | 1–32 bpp, RLE, V4/V5 headers | 24/32 bpp | | ICO | yes (BMP and PNG entries; each size is a frame) | yes | Detection is by content, not extension. `Image.Identify` and `Image.TryIdentify` report format, size, frame count and metadata without decoding pixels. # OneImage.Web: image processing for ASP.NET Core `RecursiveAI.OneImage.Web` resizes, crops, converts and recompresses images on request, driven by URL commands, and caches the results on disk. It is built on the OneImage library, so a thumbnail of a 24 MP JPEG is decoded at reduced size rather than in full. It is a separate package: the core `RecursiveAI.OneImage` package stays free of ASP.NET Core. Target framework `net10.0`; no reflection, trimmable and NativeAOT-compatible (Razor tag helpers aside, which Razor itself does not support under AOT). Contents: [quick start](#quick-start) · [commands](#commands) · [ImageSharp.Web compatibility](#imagesharpweb-compatibility) · [security defaults](#security-defaults) · [signed URLs](#signed-urls) · [caching](#caching-and-conditional-requests) · [providers](#providers) · [hooks](#hooks) · [how it works](#how-it-works) · [errors](#status-codes) ## Quick start ```sh dotnet add package RecursiveAI.OneImage.Web ``` ```csharp using OneImage.Web; var builder = WebApplication.CreateBuilder(args); builder.Services.AddOneImage(); var app = builder.Build(); app.UseOneImage(); // before UseStaticFiles app.UseStaticFiles(); app.Run(); ``` Now every image under `wwwroot` can be asked for in another size or format: ```text /photos/cat.jpg?width=400 400 px wide, same format, aspect ratio kept /photos/cat.jpg?w=300&h=300&fit=cover 300 x 300, cropped around the center /photos/cat.jpg?width=800&format=webp&q=80 WebP at quality 80 ``` A request without commands is not touched and falls through to the static file middleware. `AddOneImage` takes the settings and returns a builder: ```csharp builder.Services.AddOneImage(o => { o.MaxWidth = 2000; o.Presets["thumb"] = "width=200&height=200&fit=cover&format=webp"; o.HmacSecretKey = Convert.FromBase64String(builder.Configuration["Images:Key"]!); }) .ConfigureCache(c => c.CacheRootPath = "/var/cache/images"); ``` In Razor, `@addTagHelper *, OneImage.Web` enables ``, which writes the URL with the commands and, when a key is configured, the signature. In code, `ImageUrlSigner.Sign("/photos/cat.jpg?width=400")` does the same. ## Commands Names are case-insensitive. Each command may appear once; two spellings of the same command with different values fail with 400. | Command (aliases) | Values | Default | Meaning | |---|---|---|---| | `width` (`w`), `height` (`h`) | 1 to `MaxWidth` / `MaxHeight` | none | Target size. With one of them the other follows the aspect ratio. With none the size is kept. | | `fit` | `contain`, `cover` (`crop`), `stretch`, `pad` | `OneImageOptions.DefaultFit` (`contain`) | How the image fills a width-and-height box. | | `anchor` (`ranchor`) | `center`, `top`, `bottom`, `left`, `right`, `topleft`, `topright`, `bottomleft`, `bottomright`, `smart` | `center` | Which part a crop keeps or where padding goes. `smart` picks the busiest part. | | `focus` (`xy`) | `x,y`, each 0 to 1 | none | A focal point for `cover` with both sizes; the crop is centered on it as far as the edges allow. Replaces `anchor`. | | `upscale` | `1`/`0`, `true`/`false` | `DefaultUpscale` (`false`) | Allow enlarging past the source size. | | `filter` (`rsampler`) | `auto`, `nearest`, `box`, `bilinear` (`triangle`), `catmullrom` (`bicubic`), `mitchell`, `lanczos2`, `lanczos3` | `auto` | Resampling filter. Lanczos3 when shrinking, Catmull-Rom when enlarging. Resampling always happens in linear light. | | `bg` | color | none | Padding color and the color transparency is flattened onto. | | `format` (`fm`) | `jpeg`/`jpg`, `png`, `webp`, `gif`, `bmp` (those in `AllowedFormats`) | the source format if allowed, else PNG | Output format. | | `quality` (`q`) | 1 to 100 | encoder default (75) | JPEG and WebP quality. Ignored for other formats. | | `orient` (`autoorient`) | `1`/`0` | `1` | Apply the EXIF orientation. | | `preset` | a name from `Presets` | none | A stored command string. Explicit commands override its values. | Colors are `rgb`, `rgba`, `rrggbb` or `rrggbbaa` hex (with or without `#`, which must be sent as `%23`), `r,g,b[,a]` numbers, or `white`, `black`, `transparent`, `red`, `green`, `blue`, `yellow`, `gray`. **Unknown parameters** are ignored by default and take no part in the cache key, so `?width=100&utm_source=x` and `?width=100` are one cache entry. `UnknownCommands = Reject` makes them 400 errors (list the ones that belong to other middleware in `IgnoredParameters`). A request with no recognized command is not processed. **Invalid values** (`width=abc`, `quality=0`, an unknown format or preset) are 400 errors; nothing is silently clamped. **Presets only.** `PresetsOnly = true` honors `preset` and nothing else; all other parameters are ignored. The set of possible results is then the presets times your files, which is the right setting for a public site that does not sign its URLs. ## ImageSharp.Web compatibility The ImageSharp.Web 3.2.0 spellings are accepted alongside ours, so existing URLs keep working: | ImageSharp.Web | Here | |---|---| | `width`, `height` | same | | `rmode=crop` / `pad` / `boxpad` / `max` / `min` / `stretch` | `crop` is `fit=cover` with enlarging, `pad` is `fit=pad`, `boxpad` is `fit=pad` without enlarging, `max` is `fit=contain` without enlarging, `min` scales to cover the box without cropping, `stretch` is `fit=stretch`. `manual` is not supported and fails with 400. | | `ranchor` / `anchor`, `xy` | same (`anchor` also accepts `smart`) | | `rsampler` | `nearest`, `box`, `triangle`, `bicubic`, `mitchell`, `lanczos2`, `lanczos3`. `welch`, `hermite`, `spline`, `robidoux`, `lanczos5` and `lanczos8` are not available and fail with 400. | | `color` | padding color | | `bgcolor` | the color transparency is flattened onto | | `orient` / `autoorient`, `format`, `quality` | same | | `compand` | accepted and ignored: resampling is always in linear light | | `preset` (preset-only parser) | `PresetsOnly` | Defaults differ in one place: ImageSharp.Web crops when a width and a height are given without a mode, and enlarges. Call `options.UseImageSharpDefaults()` (it sets `DefaultFit = Cover` and `DefaultUpscale = true`) for the same behavior. HMAC tokens are not interchangeable with ImageSharp.Web's (see below); regenerate signed URLs. Setting mapping: `BrowserMaxAge`, `CacheMaxAge`, `CacheHashLength`, `OnParseCommandsAsync`, `OnBeforeSaveAsync`, `OnProcessedAsync` and `OnPrepareResponseAsync` keep their names; `HMACSecretKey` is `HmacSecretKey`. ImageSharp.Web's custom `IImageWebProcessor` classes have no equivalent: do the extra work in `OnBeforeSaveAsync`, which receives the lazy `Image`. ## Security defaults The defaults assume the endpoint is public and unauthenticated. - **Output size is bounded.** `MaxWidth` and `MaxHeight` default to 4096 and `MaxOutputPixels` to 16,777,216. They apply to requested values (400 at parse) and to the resulting image, including when no size is requested, so re-encoding a 24 MP photo needs a higher `MaxOutputPixels`. - **Source size is bounded.** `MaxSourceBytes` (64 MiB, 413) and `SourceLimits`, the library's decoding limits with 2^27 pixels per frame, 2^28 in total and 500 frames (413). A decompression-bomb file fails before its pixels are allocated. - **Query length** is capped at 1024 characters. - **Work is bounded.** At most `MaxConcurrentProcessing` images (the processor count, at least 2) are processed at once; others wait. - **Cache growth.** Without a signing key, every distinct valid combination of commands creates a cache entry, and nothing deletes entries. On a public site use `HmacSecretKey` or `PresetsOnly`, or restrict values in an `OnParseCommandsAsync` hook (for example snapping widths to a list). Stale and unwanted entries can be removed by deleting files in the cache folder at any time. - **No path escapes.** Providers never serve a path outside their root; the physical provider also hides dot-files. Only paths with a known image extension are looked at. - **Metadata.** EXIF, XMP and IPTC are removed (they can contain GPS positions); the ICC profile stays and the pixels are converted to sRGB as `Image.Thumbnail` does. `KeepMetadata = true` keeps them. - **Cache folder** defaults to `oneimage-cache` in the content root, not under `wwwroot`, so cached files are only reachable through the middleware. - Error responses are fixed short texts; they never echo input or file paths. ## Signed URLs Set `HmacSecretKey` (at least 32 random bytes) and every request that carries commands must carry a valid `hmac` parameter, or it fails with **403**. Choose `HmacAlgorithm.Sha256` (default), `Sha384` or `Sha512`. The token is the lower-case hex HMAC of the request path (decoded, with the path base) followed by `?` and the raw query string without the `hmac` parameter, exactly as sent. Any change to the path or the query, including the order of parameters, invalidates it. The token is compared in constant time. There is no expiry in this version; rotate the key to revoke. ```csharp var signer = app.Services.GetRequiredService(); string url = signer.Sign("/photos/cat.jpg?width=400&format=webp"); // /photos/cat.jpg?width=400&format=webp&hmac=3f8a... ``` `HmacUtilities.ComputeToken(value, key, algorithm)` and `HmacUtilities.SignedText(path, query)` are available for signing from other code. Requests without recognized commands need no token (they are not processed), and presets need one too when a key is set. ## Caching and conditional requests **Cache key.** `PathCacheKey` builds `request path + newline + canonical commands`. The canonical text is the *normalized* command set with every default applied (`ProcessingCommands.ToCanonicalString()`, for example `v1;w=400;fit=Contain;anchor=Center;filter=Auto;up=0;fmt=webp;q=80;orient=1;meta=0`), plus `CacheSalt` if set. Aliases, parameter order, case and unknown parameters therefore do not create separate entries. `HostPathCacheKey` also includes the host. Replace the key with `SetCacheKey()`. **Cache hash and layout.** `Sha256CacheHash` hashes the key; the first `CacheHashLength` hex characters (default 32, 12 to 64) name the files, spread over `CacheFolderDepth` folders (default 2) of `CacheFolderNameLength` characters (default 2): `cache/ab/cd/abcd....img` and a `.meta` text record. The record holds the full hash, so a collision on the shortened name causes a reprocess, never a wrong image. **Invalidation on source change.** The record stores the source's modification time and length. On each request the provider's current values are compared; any difference reprocesses the image and replaces the entry. Entries older than `CacheMaxAge` (365 days) are replaced too. `CacheSalt` invalidates everything at once. **Writing.** Files are written under a temporary name and renamed, the record last, so readers never see partial entries and several processes can share a folder. If a cache file was deleted, the next request rebuilds it. A failing cache write is logged and the response is still served. **Response headers.** `ETag` (derived from the key hash and the source version, so it is known without reading the cache), `Last-Modified` (the source's), `Cache-Control: public, max-age=` (7 days; zero sends `no-cache`), `Content-Type`, `Content-Length` and `X-Content-Type-Options: nosniff`. `If-None-Match` (weak comparison, `*`) and `If-Modified-Since` produce **304** without reading or processing anything. `HEAD` is answered with the headers only. **Concurrent identical requests** are processed once: the first request starts the work, the others wait for it (and may give up if their client disconnects, without cancelling the work). The source version is part of that wait key, so a request for a newer source never receives the older result. ## Providers A provider finds the source file for a request. Providers are asked in registration order until one returns a source; a provider can restrict itself to a path prefix. | Provider | Source | |---|---| | `WebRootImageProvider` | `wwwroot` (registered by default) | | `PhysicalFileImageProvider(root, behavior, prefix)` | any folder, under a URL prefix | | `FileProviderImageProvider(IFileProvider, behavior, prefix)` | any `IFileProvider` | | your own `IImageProvider` | databases, blob storage, anything with a modified time and a length | `ProcessingBehavior.CommandOnly` (default) processes only requests with commands; `All` also re-encodes plain requests. ```csharp builder.Services.AddOneImage() .ClearProviders() .AddProvider(_ => new PhysicalFileImageProvider("/srv/photos", requestPathPrefix: "/photos")) .AddProvider(); ``` `SetCache()`, `SetCacheKey()` and `SetCacheHash()` replace the other parts. An `IImageCache` needs two methods (get and set by hash). ## Hooks | Hook | When | Typical use | |---|---|---| | `OnParseCommandsAsync` | after the query was read and the signature checked | add `format=webp` when the browser accepts it (add a `Vary: Accept` header in `OnPrepareResponseAsync`), snap sizes | | `OnBeforeSaveAsync` | on the lazy result image, before encoding | watermark, extra filters (change `CacheSalt` when it changes) | | `OnProcessedAsync` | after an image was actually processed, not on cache hits | metrics | | `OnPrepareResponseAsync` | before any response of the middleware, 304 included | headers | ## How it works For an image request the middleware, in order: ignores non-GET/HEAD requests and paths without an image extension; reads the commands (expanding a preset); verifies the signature; runs `OnParseCommandsAsync`; asks the providers for the source; validates and normalizes the commands against the limits; computes the cache key, hash and ETag; answers 304 if the client's validators match; looks in the cache and checks the source version; otherwise processes once (see above) and stores the result. Processing is the library's thumbnail pipeline: the source bytes are read once (bounded by `MaxSourceBytes`), decoded lazily with the configured limits, resized (JPEG sources are decoded at 1/2, 1/4 or 1/8 size when the target allows), converted to sRGB, stripped of EXIF/XMP/IPTC, flattened for formats without alpha, and encoded. ## Status codes | Code | When | |---|---| | 200, 304 | served; not modified | | 400 | invalid, unknown (when rejecting), conflicting or over-limit command; long query; result over the output limits | | 403 | missing or wrong `hmac` while a key is configured | | 413 | source file over `MaxSourceBytes`, or over the decoding limits | | 415 | the source is not a readable image | | 422 | the image could not be processed | | (next middleware) | no recognized command, no such file, other method or extension | ## Not included yet Cloud storage providers and caches, per-URL expiry for signed links, a fluent URL builder, an in-memory result cache in front of the disk cache, and automatic cache cleanup. # Text Fonts and text for `Image` and `Canvas`, in the separate package `RecursiveAI.OneImage.Text` (namespace `OneImage.Text`, one dependency besides OneImage, the font library [`RecursiveAI.OneFont`](https://www.nuget.org/packages/RecursiveAI.OneFont) 1.2 or later, which has none of its own). Text is drawn through the vector drawing layer: the laid-out text becomes one `Path` of glyph outlines, filled with a `Brush` and/or stroked with a `Pen` like any other path. See [drawing.md](https://recursiveai.org/oneimage/docs/drawing/) for brushes, pens, blend modes and anti-aliasing. ```csharp using OneImage; using OneImage.Drawing; using OneImage.Text; var fonts = new FontCollection(); FontFamily family = fonts.Add("NotoSans-Regular.ttf"); Font font = family.CreateFont(32); // 32 points = 32 px at the default 72 dpi var options = new TextOptions(font) { Origin = new(40, 60), WrappingLength = 400, HorizontalAlignment = HorizontalAlignment.Left, }; Image img = Image.Load("photo.jpg") .DrawText("Hello, world. Wrapped at 400 px.", options, Brush.Solid(new Rgba32(255, 255, 255, 255))); TextMetrics m = TextMeasurer.Measure("Hello", options); // advance size, ink bounds, lines Path outline = TextBuilder.GeneratePath("Hello", options); // fill, stroke, transform or clip it yourself ``` ## Built on OneFont Font parsing and text shaping come from `RecursiveAI.OneFont`; this package adds what is specific to drawing text on images. From OneFont: - TrueType, OpenType (CFF) and TrueType Collection parsing, the character map, metrics, kerning and unhinted glyph outlines; - the GSUB/GPOS engine and the Arabic, Indic, Universal Shaping Engine, Thai and Lao shapers, through `ShapeCodePoints` (code points in, glyphs with their code points and clusters out, with the script carried from run to run) and `ShapedRun` (flat arrays, no allocation per call once warm); - the Unicode bidirectional algorithm (levels, rule L1, display order), line break opportunities, grapheme clusters, East Asian width, joining types. Here: WOFF 1.0 and WOFF 2.0 (with the library's own inflate and Brotli), which are unpacked and handed to OneFont as a plain font; TrueType hinting; vertical metrics; COLR/CPAL color glyphs; the Vertical_Orientation data (UAX #50); word breaking modes and justification; the layout of paragraphs, runs, fallback, decorations and paths; measuring; and turning outlines into paths and pixels. A glyph id the shaper returns that the font does not have (a damaged character map) is drawn and measured as the missing glyph. Damage in a font is reported as this package's `FontFormatException`, not OneFont's. ## Fonts `FontCollection` loads TrueType (glyf, quadratic) and OpenType with CFF outlines (CFF1: name-keyed and CID-keyed, local and global subroutines, flex), TrueType Collections (`AddCollection` adds every face) WOFF 1.0 (decompressed with the library's own inflate) and WOFF 2.0 (see [WOFF2](#woff2)). CFF2 is not read, and Type 1 hinting is ignored; TrueType instructions run only when asked for (see [Hinting](#hinting)). A variable font draws its default instance. Add from a path, a `Stream` or memory; faces with the same family name join one `FontFamily`, one face per `FontStyle`. `FontFamily.CreateFont(size, style)` picks the closest face when the style is missing and never synthesizes bold or slant. Tables read: `cmap` (formats 0, 4, 6, 12, 13; the best Unicode subtable), `head`, `hhea`, `hmtx`, `maxp`, `OS/2`, `name`, `post`, `loca`/`glyf`, `CFF `, `kern` (format 0, Microsoft and Apple headers), `GDEF`, `GSUB` and `GPOS` (see [Shaping](#shaping)), and `COLR` and `CPAL` (see [Color fonts](#color-fonts)). Fonts are untrusted input: parsing is bounds-checked safe code, counts are checked against table lengths before anything is allocated, composite glyphs are flattened with an explicit stack (depth 8, 512 components), and the charstring interpreter bounds its operand stack, subroutine depth and operator count. A malformed font throws `FontFormatException` (an `ImageFormatException`), at load for the header tables and lazily, when the glyph data is first used, for the rest. Damaged `GDEF`, `GSUB`, `GPOS`, `COLR` and `CPAL` tables never throw: what cannot be read is ignored (see [Shaping](#shaping)). `fuzz/run.sh font` covers all of it, and `fuzz/run.sh brotli` the decompressor under WOFF2. `FontMetrics` reports design units: `Ascender`, `Descender` (negative), `LineGap`, `LineHeight`, underline and strikeout, x-height and cap height. The vertical metrics are the OS/2 typographic ones when the font sets the `USE_TYPO_METRICS` flag, else `hhea`. ## WOFF2 `FontCollection.Add` and `AddCollection` read WOFF 2.0 files (W3C WOFF 2.0, 2018) from a path, a stream or memory, whether they hold one font or a collection (`AddCollection` adds every face; shared tables are decoded once). Everything the format defines is read: the table directory with known and custom tags, the Brotli-compressed table block, the transformed `glyf`/`loca` pair (the streams of contour counts, points, flags, coordinates, composites, boxes and instructions, with the overlap flags) and the transformed `hmtx` (side bearings rebuilt from the glyph boxes). The metadata and private blocks are skipped. A transformed `glyf` gets a plain `loca` back in the format the header names, and `head.indexToLocFormat` is corrected if the file disagrees with itself. Reconstructed glyphs are written with plain 16-bit coordinates, so the table can be larger than the original; the outlines are the same. The decompressor is the library's own Brotli (RFC 7932, decode only, safe managed code, with the static dictionary embedded as a resource); the BCL's `BrotliDecoder` wraps a native library and is not used. The Brotli stream is untrusted like everything else in the file: the output buffer grows with the data actually produced and never to a size read from the file, and it stops at the sum of the table sizes the directory declares (each at most 128 MiB, all at most 256 MiB) and at 2048 bytes of output per input byte (plus 64 KiB), so a tiny file that declares a huge table fails with a `FontFormatException` after allocating a few kilobytes. The window is the output itself, so there is no separate history buffer. Unknown table transforms, a missing composite box, a mismatched `glyf`/`loca` pair and extra or missing table bytes are rejected, as the specification says. Measured on the 45 KB WOFF2 of the Noto Sans shaping subset (shared workstation, load 6 to 12, .NET 10.0.11, BenchmarkDotNet, local smoke numbers): loading it takes 1.0 ms and 557 KB against SixLabors.Fonts 2.1.3's 1.35 ms and 2.5 MB. The Brotli decoder alone runs at roughly 330 MB/s on text and 0.9 GB/s on font-like binary data, about half the speed of the native decoder behind `System.IO.Compression`. ## Units and layout `Font.Size` is in points. Pixels = points × `TextOptions.Dpi` / 72, and the default dpi is 72, so a size is the pixel height of the em square. Everything else (origin, wrapping length, results) is pixels, y down. By default there is no hinting and no rounding ([Hinting](#hinting) is opt-in and changes drawn outlines only): advances, positions and bounds are unrounded floats, so a string's advance is the exact sum of its glyph advances in font units times the scale. - **Lines.** Line breaks (LF, CR, CRLF, VT, FF, NEL, U+2028, U+2029) always start a line, except one at the very end of the text, which does not open an empty line. The line pitch is `Size × Dpi/72 × LineSpacing`; that is one em at the default spacing, not the font's own line height. The font's ascent and descent are centered in the line box: baseline = ascender − (natural line height − pitch) / 2. - **Wrapping.** `WrappingLength` ≤ 0 means none. Lines end at the break opportunities of the Unicode line breaking algorithm (see [Line breaking and segmentation](#line-breaking-and-segmentation)). Spaces at the end of a line may hang past the limit and are left out of its width. By default a word longer than the limit keeps its own line and overflows (`WordBreaking.BreakWord` breaks it). Tabs advance `TabWidth` space widths (default 4). - **Kerning** is on by default (`KerningMode.Normal`): the `kern` feature of the font (GPOS pair adjustment), or the legacy `kern` table when the font has no GPOS kerning. `KerningMode.None` switches the `kern` feature off; see [Shaping](#shaping). - **Alignment.** `HorizontalAlignment` puts a box on the origin (left edge, center or right edge); the box is as wide as the widest line. `TextAlignment` Start keeps lines at the box's left edge. Center and End move each line by its slack against the wrapping length (the widest line when there is none), placing it in a box of that width positioned by the horizontal alignment. This reproduces ImageSharp's behavior, including that a centered block does not center its lines unless asked to. `VerticalAlignment` puts the block (lines × pitch tall) on the origin. - **Measuring.** `TextMeasurer.Measure` gives `TextMetrics`: `Advance` (widest line without trailing spaces × lines × pitch; independent of origin and alignment), `Bounds` (the ink, exact curve extents, positioned), `LineCount` and per-line `LineMetrics`. `GetCharacterBounds` gives each character's (grapheme cluster's) advance box and ink box. Zero-width format characters (U+200B–U+200F, U+2060, U+FEFF, soft hyphen, bidi controls) are dropped from the layout. - **Missing glyphs** draw the font's `.notdef` glyph, unless a fallback family has the character (see [Font fallback](#font-fallback)). ## Shaping Text is shaped before it is laid out: code points go through the font's character map, then GSUB substitutions, then GPOS positioning, and the result is a run of glyphs with advances and offsets. Layout (wrapping, alignment, measuring, drawing) works on those glyphs only. The engine follows the OpenType specification and works for every script that needs no reordering, plus Arabic-style joining and Hebrew (see [Arabic and Hebrew](#arabic-and-hebrew)) and the Indic, Southeast Asian and USE scripts (see [Complex scripts](#complex-scripts-indic-southeast-asian-and-the-universal-shaping-engine)). - **Tables.** `GDEF` glyph classes, mark attachment classes and mark glyph sets. `GSUB` lookup types 1 single, 2 multiple, 3 alternate, 4 ligature, 5 context, 6 chained context (all three formats of each context), 7 extension and 8 reverse chaining single. `GPOS` types 1 single, 2 pair (formats 1 and 2), 3 cursive, 4 mark-to-base, 5 mark-to-ligature, 6 mark-to-mark, 7 context, 8 chained context and 9 extension. All lookup flags apply: ignore base glyphs, ligatures and marks, mark attachment type, and mark filtering sets (a filtering set wins over an attachment type). Device and variation-index tables inside value records and anchors are skipped, since text is unhinted and a variable font draws its default instance. - **Lookup order.** Lookups of the enabled features run in lookup-list order, as the specification says, in two stages: `ccmp` and `locl` first, then everything else. So `smcp` on `fi` changes the letters to small capitals before `liga` looks for a ligature, and the ligature does not form; ImageSharp ligates first. - **Script and language.** Each stretch of one script (Latin, Greek, Cyrillic; spaces, digits and punctuation join the stretch they are in; combining marks follow their base; nothing substitutes or kerns across a change of script) is shaped with that script's feature set, falling back to `DFLT`, `dflt`, then `latn` when the font lacks the script. `TextOptions.Language` (a BCP 47 tag such as `"sr"`, `"ro"`, `"tr"`, `"ca"`, or an OpenType language tag in capitals such as `"SRB"`) picks the font's language system for the script; a language the font does not have uses the script's default. The default is no language. - **Features.** The defaults for horizontal text are `ccmp`, `locl`, `rlig`, `liga`, `clig`, `calt`, `rclt`, `kern`, `mark`, `mkmk` and `curs`. `TextOptions.Features` takes `FontFeature` settings in order, later ones replacing earlier ones: `FontFeature.On("smcp")`, `FontFeature.Off("liga")`, or a value (`new FontFeature("salt", 2)` picks the second alternate; a value past the font's alternates leaves the glyph alone). `Kerning = KerningMode.None` is the same as `Off("kern")`, and an explicit `kern` setting wins over it. A tag the font does not have does nothing. A feature outside the default stages (`smcp`, `onum`, `dlig`, `ss01`...) runs in the second stage with the defaults. - **Marks.** A mark is placed from the anchors of the font's `mark` and `mkmk` lookups: on the base, on a ligature's component (a mark that sat between components in the text belongs to the component it followed), or on the mark before it. A combining sequence the font composes through `ccmp` (a base and a mark becoming one glyph, or several marks becoming one) is substituted before positioning. The offsets move the glyph's outline only: advances, wrapping and `GetCharacterBounds` boxes still follow the pen. - **Clusters.** A glyph stands for the characters from its `Index` on; a ligature is one glyph for several characters, so `GetCharacterBounds` returns one box for `fi`, starting at the `f`. A zero width non-joiner (U+200C), a zero width space (U+200B) and a soft hyphen (U+00AD) end a shaping run, so nothing ligates or kerns across them (the last two are line break opportunities, which a ligature would otherwise swallow); the other zero width format characters are dropped. A base and its combining marks, and the glyphs of one character the font splits into several, share one box, so wrapping and `GetCharacterBounds` work on grapheme clusters whatever the glyph run looks like; a line never ends inside a ligature. - **Limits.** Fonts are untrusted. Every read is bounds-checked and a subtable that cannot be read is skipped. Contexts nest lookups at most 6 deep. A run may grow to eight glyphs per character plus 256, and may attempt at most 65,536 + 1,024 per character subtable applications (and 2^26 overall; a subtable that fails to read counts as 2,048, and a subtable the engine skips because it has a type it cannot use counts as 1, so a lookup of tens of thousands of such subtables cannot cost that many steps at every glyph); a run that exhausts the budget keeps what it has and applies no further lookups. A damaged GSUB/GPOS/GDEF header means the table is ignored, as if the font had none. Nothing here throws. What the engine does not do yet: canonical reordering and composition of combining marks (a mark sequence is shaped as typed; the font's own `ccmp` handles the compositions it has), contour-point anchors, feature ranges inside a string, Apple `morx`/`kerx` layout, and the `GSUB`/`GPOS` FeatureVariations table. A script-specific shaper plugs in as a `ScriptShaper` (its feature stages and a hook on the glyph run) without touching the lookup engine. ## Direction and right-to-left text Text is split into paragraphs at line feeds, and each paragraph runs the Unicode Bidirectional Algorithm (UAX #9, Unicode 17 data, from OneFont, which passes the official `BidiTest.txt` and `BidiCharacterTest.txt` in full). `TextOptions.TextDirection` sets the base direction: `Auto` (the default) takes each paragraph's direction from its first strong character and falls back to left to right; `LeftToRight` and `RightToLeft` force the paragraph level, and the algorithm still reorders embedded runs of the other direction. Plain left-to-right text, with no right-to-left capable character, skips the algorithm entirely. - **Reordering.** Runs are reordered per wrapped line (rules L1 and L2), on the characters of the line, so a line break in the middle of a mixed paragraph reorders each line on its own. Trailing white space of a line takes the paragraph level and hangs past the line's visible end. Brackets are mirrored (rule L4 and bracket pairs, rule N0), by code point, or through the font's `rtlm` feature when it has one. Explicit embeddings, overrides and isolates (up to depth 125) are honored; their control characters have no advance and no glyph. - **Alignment.** `TextAlignment.Start` and `End` follow the paragraph direction: `Start` is the right edge of a right-to-left paragraph. `HorizontalAlignment` is the physical left, center or right of the block and ignores direction. Justification stretches every line but the last and counts hanging spaces out. - **Metrics.** `Lines` report characters in logical order: `Start` and `Length` are always a range of the string. `GetCharacterBounds` boxes are placed where the characters are drawn. A line never breaks inside a grapheme cluster. - **Limits.** Embedding depth is bounded at 125 and the bracket stack at 63, as the algorithm says; run counts are bounded by the text length. Hostile text (160,000 characters of nested controls, brackets or alternating directions) lays out in time linear in its length (45 to 255 ms in the tests), and the `bidi` fuzz target covers it. ## Arabic and Hebrew Arabic, Syriac and N'Ko go through a joining shaper. Each letter's form comes from the Joining_Type of its neighbours (transparent marks and ZWJ/ZWNJ are looked through or break the join as Unicode says), and the form features run in this order: `ccmp`, `locl`, `rtlm`; then `isol`, `fina`, `fin2`, `fin3`, `medi`, `med2`, `init` (each only on the letters of its form); then `rlig`; then `calt`; then `liga`, `clig`, `rclt`, `mset`; and `kern`, `mark`, `mkmk`, `curs` for positioning. Lam-alef and other ligatures come from the font's lookups, so a font without them gets plain joined forms. Digits after Arabic text pick up the Arabic script's lookups. Syriac Alaph forms (`fin2`, `fin3`, `med2` selection) are not implemented. Cursive attachment in right-to-left runs is implemented but no test font has `curs`, so it is untested. Hebrew needs no joining: the generic shaper and the bidirectional algorithm place the letters, and points and cantillation marks attach through the font's `mark` and `mkmk` lookups. A mark keeps its offset from its base when a run is reversed, so marks stay on their letters. Kerning does not cross a change of direction. The script of digits, spaces and punctuation is taken from the paragraph's neighbouring letters, so they use the lookups of the text around them. ### Agreement with SixLabors.Fonts Checked against SixLabors.Fonts 2.1.3 through its public API, on one merged Noto Sans / Noto Sans Arabic / Noto Sans Hebrew subset (`tests/testdata/fonts/NotoSans-Bidi-Subset.ttf`, SIL Open Font License) covering Arabic joining forms, lam-alef, marks, tatweel, Arabic-Indic digits, Hebrew with points, and mixed English, Arabic and Hebrew paragraphs with numbers and brackets, in `Auto` and `RightToLeft`. Glyph ids are equal; glyph origins agree to 0.01 px except in the cases below. Line counts, line widths and the glyph order of each line are equal for four mixed paragraphs at four wrapping lengths. Deliberate differences: - **(a) Kerning across Hebrew points.** The font's kern lookup ignores marks, so we kern the letters on either side of a point; SixLabors.Fonts does not. Up to 2 px at 40 px; identical with kerning off. - **(b) The space after a Hebrew letter** across a change of direction is 10 font units (0.4 px at 40 px) wider in SixLabors.Fonts; we use the font's advance. - **(c) Direction controls.** SixLabors.Fonts gives embedding, isolate and override controls a 0.6 em advance each; we give them none. Glyph ids still agree. - **(d) Forced direction.** With `TextDirection.LeftToRight` SixLabors.Fonts does no reordering at all, and with `RightToLeft` it reverses the whole line, even Latin words and digits. We apply UAX #9 with the paragraph level forced, so Latin words and numbers in a right-to-left paragraph stay readable. - **(e) One direction per text or per paragraph.** SixLabors.Fonts takes one direction for the whole string; we take it per paragraph (UAX #9 rule P1), so a string with an English line and an Arabic line lays out each in its own direction. - **(f) Alignment of wrapped right-to-left paragraphs** that mix Latin words or numbers: SixLabors.Fonts left-aligns some lines of such a paragraph; every line of ours starts at the paragraph's start edge, the right. - **(g) Font fallback.** With a fallback face SixLabors.Fonts applies the primary font's GDEF and GPOS to fallback glyph ids, and digits after Hebrew in a Hebrew-primary font come out with zero advance. We shape each face with its own tables. ## Complex scripts: Indic, Southeast Asian and the Universal Shaping Engine Three shapers, in OneFont, are chosen by the script of a run (the tags, Indic_Syllabic_Category, Indic_Positional_Category and the canonical two-part decompositions come from the Unicode Character Database 17.0.0). - **Indic shaper:** Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, Telugu, Kannada and Malayalam, following Microsoft's Indic script-development documents (the v2 tags `dev2`, `bng2`... first, then the v1 tags). Text is cut into syllables (consonant clusters with halants, ZWJ/ZWNJ, matras, nukta, modifiers, standalone vowels), split vowels are decomposed, the base consonant is found, a reph is moved, pre-base matras go to the front, and features run in the documented order: `locl`, `ccmp`, then `nukt`, `akhn`, `rphf`, `rkrf`, `pref`, `blwf`, `abvf`, `half`, `pstf`, `vatu`, `cjct`, then `init`, `pres`, `abvs`, `blws`, `psts`, `haln`, `dist`, `abvm`, `blwm` with the default positioning features. A mark with no base gets a dotted circle (U+25CC) as its base. - **Universal Shaping Engine shaper:** Khmer, Myanmar, Sinhala, Tibetan, Balinese, Javanese and the other scripts of the USE list, from the USE specification (syllable categories from the Indic_Syllabic_Category and Indic_Positional_Category properties) with Khmer and Myanmar specifics: coeng + ro, pre-base vowels and medials, the Khmer split vowels (U+17BE, 17BF, 17C0, 17C4, 17C5 become U+17C1 plus a sign), kinzi and stacked consonants. - **Thai and Lao:** SARA AM (U+0E33, U+0EB3) is split into NIKHAHIT and SARA AA, and the nikhahit moves in front of the tone mark that precedes it, as the Thai script document says. Marks are placed by the font's `mark`/`mkmk` lookups and contextual substitutions. - **Line breaking** for Thai, Lao, Khmer and Myanmar stays UAX #14 without dictionaries: their letters are class SA and are treated as letters, so there is a break opportunity only at spaces, punctuation and explicit zero width spaces, not inside a word. Text without spaces therefore wraps only where the author put a ZWSP. Grapheme clusters (UAX #29) keep a syllable's marks with their base. - **Limits.** A syllable is cut after 48 glyphs; runs over 4,096 glyphs are shaped in pieces of 2,048 glyphs cut at syllable boundaries, so the cost is linear (400,000 hostile characters shape in under 2 s). Reordering and dotted circle insertion are bounded by the syllable cut. Fonts with damaged tables behave as for any script (see Shaping). - **Choices that follow the documents, not the reference.** ZWJ and ZWNJ are never skipped by lookup flags; their glyphs are removed before positioning. ZWJ after a consonant and halant asks for the half form, ZWNJ denies it. A reph forms only when the font's `rphf` changes Ra + halant. Malayalam needs a ZWJ for a reph. The base consonant is found with probes of the font's `pref`, `blwf` and `pstf` lookups. A syllable's characters share one cluster value. ### Agreement with SixLabors.Fonts Checked against SixLabors.Fonts 2.1.3 through its public API with 17 Noto subsets (`tests/testdata/fonts/Noto*-Subset-Regular.ttf`, SIL Open Font License, made by `tools/TextFonts/make-complex-fonts.py`) on 1,042 cases (`tests/testdata/complex/*.txt`: matras, reph, half forms, conjuncts, nukta, joiners, broken clusters, Tamil/Malayalam split vowels, Khmer coeng and pre-base vowels, Myanmar medials, Sinhala and Tibetan stacks, SARA AM). Glyph ids must be equal and glyph origins within 0.01 font units. The reference throws on 26 of the cases (NullReferenceException or IndexOutOfRangeException inside SixLabors.Fonts); those are not compared. | script | cases | exact match | reference throws | deliberate differences | |---|---|---|---|---| | Devanagari | 157 | 149 | 0 | 8 | | Bengali | 83 | 75 | 0 | 8 | | Gurmukhi | 57 | 43 | 11 | 3 | | Gujarati | 75 | 65 | 1 | 9 | | Oriya | 67 | 63 | 0 | 4 | | Tamil | 76 | 73 | 0 | 3 | | Telugu | 75 | 61 | 4 | 10 | | Kannada | 71 | 58 | 0 | 13 | | Malayalam | 89 | 76 | 5 | 8 | | Khmer | 54 | 43 | 5 | 6 | | Myanmar | 39 | 16 | 0 | 23 | | Sinhala | 32 | 26 | 0 | 6 | | Tibetan | 31 | 25 | 0 | 6 | | Thai | 45 | 41 | 0 | 4 | | Lao | 27 | 25 | 0 | 2 | | Balinese | 35 | 31 | 0 | 4 | | Javanese | 29 | 27 | 0 | 2 | | total | 1,042 | 897 | 26 | 119 | Every difference is listed in `tests/testdata/complex/known-differences.txt` with a reason; the test fails if a listed case starts to match or an unlisted one differs. The reasons, and the rule each follows: - **broken-cluster (61).** A mark without a base: we insert the dotted circle first, in logical order, and the mark follows it (a pre-base matra moves in front of the circle as it would in front of a consonant), as the Indic and USE documents describe. The reference puts the circle after the mark. - **myanmar-reorder (21).** The reference does no reordering for Myanmar. We follow USE: the pre-base vowel E (U+1031) and medial Ra (U+103C) move before the consonant, a virama joins two consonants into the font's stacked form, and a consonant + asat + virama before a consonant becomes the font's kinzi. Checked on the glyph runs: for U+1000 U+1039 U+1000 we draw the stacked glyph where the reference draws the base, a visible virama and the base again; for U+1000 U+1031 the vowel comes first; for U+1004 U+103A U+1039 U+1000 we draw the font's kinzi glyph where the reference draws raw letters. Medial ya + wa (U+103B U+103D) uses the font's own ligature for the pair. - **tibetan-extra-glyph (4).** For U+0F40 followed by U+0F73 or U+0F81 (and the sequence with U+0F71 U+0F72) we output the consonant with aa, then the vowel sign; the reference appends an extra dotted circle glyph (U+25CC), treating a valid stack as a broken one. We do not. - **joiner-kerning (4).** ZWJ/ZWNJ glyphs are removed after substitution, so pair adjustment sees the neighbours next to each other. The reference positions with the joiner in between. - **ignore-marks-pair (4), below-mark-offset (3).** Malayalam i/ii: a pair lookup with IgnoreMarks does not apply to a second glyph the GDEF table calls a mark (OpenType lookup flags); the reference applies it (5 font units). Gujarati u/uu/vocalic r: the font's contextual `dist`/`blwm` single positioning applies in ours and not in the reference. - **sara-am (5).** The nikhahit goes in front of the tone mark (Thai script document); the reference leaves the tone mark and nikhahit overlapping. - **kannada-conjunct (3).** We form the font's conjunct and conjunct + matra ligatures; the reference places the matra before the below-base consonant and breaks the ligature. - **bengali-form (4), contextual-form (8), prebase-reorder (2).** The font's contextual variants or the ordering follow the font's tables and the documents in ours; the reference skips them. Checked on the glyph runs for Bengali ka + hasant + ka (we form the font's conjunct, the reference draws ka, a visible hasant and ka), Sinhala ka + al-lakuna + ZWJ + ra + e (the left vowel comes first in ours) and Khmer coeng + ka. The remaining members (Balinese, Javanese, Thai tone-mark variants, Sinhala ra + al-lakuna + ZWJ alone) differ because ours applies the font's substitution; we did not derive each one from a rule in the documents, so treat those as unproven and not as known errors of the reference. Tolerances are exact for glyph ids and 0.01 font units for origins (lossless layout arithmetic). A font with the older v1 script tags only is shaped through the v1 tags; the v1-specific behaviors of the older engines (for example the pre-v2 reph handling) are not reproduced. ## Line breaking and segmentation Where a wrapped line may end is decided by the Unicode line breaking algorithm, UAX #14, with the data of Unicode 17.0 (in OneFont). The default rules are used as written, with the small kana of class CJ treated as NS (the annex's strict reading). All of `LineBreakTest.txt` (19,338 cases) and `GraphemeBreakTest.txt` (766 cases) pass; the tests read the unmodified files from `tests/testdata/unicode`. - **What breaks.** After spaces, hyphens (HY, HH) and en/em dashes, around the soft hyphen (U+00AD, a break after it, nothing drawn), after the zero width space, between ideographs and kana but not before closing punctuation or small kana, after a solidus before a letter ("a/b" in a URL or path), between emoji sequences and flag pairs but never inside one, and after hard breaks. It does not break in "$1,234.56", "-5.25", "12.5%", "(s)he", inside a word, after an opening bracket or quote even across spaces, before a closing bracket or quote, around the no-break space (U+00A0, U+202F), the word joiner (U+2060) or the zero width joiner, or inside a Korean syllable block. A line break character in the text always starts a new line first; the algorithm runs on each paragraph. - **Grapheme clusters.** UAX #29 extended grapheme clusters (combining marks, emoji ZWJ sequences and modifiers, flags, Hangul jamo and syllables, Indic conjuncts) are never split: a line never starts inside one in any `WordBreaking` mode, and `GetCharacterBounds` returns one box per cluster (`Index` and `CodePoint` are the cluster's first code point; the box and ink cover the whole cluster). - **`TextOptions.WordBreaking`** (needs a `WrappingLength`; names as in ImageSharp): - `Standard` (default): lines end only at the opportunities above. - `BreakAll`: any cluster boundary is an opportunity (not in front of white space), so lines are filled to the length. CJK text fills exactly too. - `KeepAll`: `Standard`, but no opportunity right after an East Asian wide or fullwidth character, so CJK text breaks only at spaces. Other text is unchanged. - `BreakWord`: `Standard`, except a word that does not fit even on a line of its own is moved to a fresh line and then broken between clusters so it fills lines instead of overflowing (the CSS `overflow-wrap: break-word` behavior). - **`TextOptions.TextJustification`** (needs a `WrappingLength`): `InterWord` adds the same width to every space inside a line (U+0020 and U+00A0, not tabs) and `InterCharacter` adds the same width after every cluster but the last, in both cases until the line is exactly the wrapping length. Like ImageSharp, every line is stretched, the last one and lines before a line break included; a line with nothing to widen, or one that is already too long, is left alone. Positions agree with SixLabors.Fonts 2.1.3 to 0.0001 px for both modes and all three text alignments. - **Cost.** Segmentation is linear, runs once per paragraph over pooled arrays and allocates nothing in steady state (tested). Table lookups are two-stage over 64-code-point blocks. ## Color fonts Color glyphs are on by default, as in SixLabors.Fonts (whose `TextOptions.ColorFontSupport` defaults to its COLR format): `TextOptions.ColorFontSupport` is `ColorFontSupport.MicrosoftColrFormat`, so `DrawText` on `Image` and `Canvas` paints the layers of COLR glyphs when the font has them. Set `ColorFontSupport.None` to draw every glyph as a plain outline filled with the brush. Fonts without COLR are not affected: ```csharp var options = new TextOptions(font) { ColorPaletteIndex = 1 }; // color on by default; palette 1 of the font img.DrawText("\u2600 sunny", options, Brush.Solid(new Rgba32(0, 0, 0, 255))); ``` - **What is read.** `COLR` version 0 (a base glyph is a stack of layer glyphs, painted bottom to top) with `CPAL` palettes (versions 0 and 1; the palette labels and types are ignored). `COLR` version 1 paint graphs are painted too, see [COLR version 1](#colr-version-1). - **Palette.** `ColorPaletteIndex` (default 0) selects the `CPAL` palette; an index past the last palette selects palette 0, and `Font.ColorPaletteCount` tells how many there are. A layer whose entry does not exist is left out. - **Foreground layers.** Palette index 0xFFFF means the text color: those layers are filled with the brush you pass, and nothing is drawn for them when there is none. Layers with a palette color are painted with it whatever the brush is (also when you pass only a pen). `opacity`, `blend`, `composition`, `antialias` and `linear` apply to every layer. - **Pen.** The pen outlines the plain glyphs and the foreground layers, over all fills, as SixLabors.Fonts does; layers with their own color get no outline. - **Order.** Plain glyphs first, then layer 0 of every color glyph, then layer 1, and so on, with layers of equal index and color merged into one fill. Colors of glyphs that overlap each other can therefore stack differently from glyph-by-glyph painting. - **Layout is unchanged:** advances, wrapping and measuring use the base glyph. `TextBuilder.GeneratePath` and measuring never depend on `ColorFontSupport`; text as a path is the monochrome outline. `Font.HasColorGlyph(codePoint)` tells whether a code point has layers. - **Damaged tables** are ignored and the font then has no color glyphs. With `ColorFontSupport.None`, or in a font or a text without color glyphs, the output is byte for byte the monochrome path. - **In runs and fallback faces.** Color glyphs work wherever a glyph comes from: a run's own font, a font of a different size, or a fallback face. The foreground entry (0xFFFF) takes the brush of the run that owns the character (the brush you pass when the run has none), layers with a palette color keep that color, and the run's pen (or the one you pass) outlines the plain and foreground parts after all fills. The palette is `ColorPaletteIndex` of the options, applied to every face (an index past a face's last palette selects its palette 0). ## COLR version 1 Fonts with a `COLR` version 1 table (`BaseGlyphList`, `LayerList`, optional `ClipList`) paint their paint graphs when `ColorFontSupport` is on (the default). Glyphs that have only version 0 records still go through the layer path above; a version 1 glyph takes precedence when a font has both. - **What is painted.** Solid fills; linear, radial and sweep gradients (with extend modes); glyph clips; layers; `PaintTransform`/`Translate`/`Scale`/`Rotate`/`Skew` (with their around-center forms); `PaintComposite` with all Porter-Duff and blend modes (`PLUS` is mapped to additive blending); `PaintColrGlyph` reuse. The glyph is drawn into a group the size of its ink and the group is filled onto the target, so opacity, `blend` and clipping apply to the whole glyph. The foreground color (palette index 0xFFFF) takes the run's brush; for a non-solid brush the color is sampled at the glyph's top-left pixel, and with no brush the foreground stops are transparent. - **Budgets.** A paint graph is flattened with an explicit stack to at most 4096 nodes and depth 64; exponential sharing, very deep graphs, truncated tables and cycles cannot exhaust time or memory. A graph over budget, or a cycle, draws the plain glyph outline instead (Skia draws nothing for a cycle). - **Differences.** Variable (`Var`) paint formats use their default values (no variation instance). Gradients interpolate non-premultiplied (straight) color, as Skia does; stops are rescaled to the first-to-last span. Edges of clip boxes are anti-aliased. Layout, measuring and `TextBuilder.GeneratePath` ignore paint graphs, as for version 0. - **Agreement.** SixLabors.Fonts 2.1.3 has no version 1 support, so the reference is SkiaSharp (Skia's COLRv1 painter) on the googlefonts/color-fonts test font (`ColrV1-TestGlyphs.ttf`, Apache-2.0): 211 glyphs at 64 px, mean difference 0.36 levels (205 of 213 glyphs below 1.0; the worst non-cycle glyph 2.5, among the clip-box glyphs), excluding the two cyclic glyphs. The Skia comparison runs on Linux x64 only and is skipped where the native asset is missing. ## Vertical layout `TextOptions.LayoutMode` takes the members of SixLabors.Fonts' `LayoutMode`: `HorizontalTopBottom` (default), `HorizontalBottomTop` (lines stack upward from the origin; each line still reads left to right), and the vertical modes `VerticalLeftRight`, `VerticalRightLeft`, `VerticalMixedLeftRight` and `VerticalMixedRightLeft`. In a vertical mode a line is a column: `WrappingLength` limits its height, `TextAlignment` works along it, and columns stack to the right (`LeftRight`) or left (`RightLeft`). `Measure(...).Advance` is (columns width, longest column). - **Metrics.** Upright glyphs advance by `vmtx` (else the line height) and sit by `VORG` or the top side bearing (else one ascender below the cell top); the `vert` and `vrt2` features are on and `kern` is off for them, unless `TextOptions.Features` says otherwise. The plain vertical modes stand every character upright. The mixed modes follow UAX #50 Vertical_Orientation (Unicode 17): `U` and `Tu` stand, others lie rotated a quarter turn clockwise and advance by their horizontal advance. `Tr` and `Tu` are treated as `R` and `U` (no per-font test for a vertical form). - **Placement.** Upright ideographs and kana (`U`, `Tu`) fill the em square, centered in the column; upright characters that would otherwise lie down (Latin in the plain modes) are centered by their ink. The rule was derived by probing SixLabors.Fonts 2.1.3. - **Not applied in vertical modes:** bidirectional reordering, decorations and text on a path. - **Agreement.** Against SixLabors.Fonts 2.1.3 on the CJK and Latin test fonts, all five non-default modes, wrapping off and at 100 px, at 40 and 24 px: 296 of 296 measurement and per-character ink cases within 0.02-0.03 px; 432 of 432 alignment cases (3 x 3 x 3 alignments, two wraps, two texts, four vertical modes). - **Differences.** (1) In the mixed modes, when one column holds both upright and rotated characters, the reference widens the column by 0.282 em with the CJK test font (24 cases; cause not derived); ours keeps the column at the line pitch. (2) Wrapped CJK text: the reference ends a line one opportunity early after an ideograph, as in horizontal text (see above). (3) The behavior listed under "Not applied". ## Hinting `TextOptions.HintingMode` is `None` by default. `HintingMode.Standard` grid-fits TrueType glyphs at the drawing size by running the font's own instructions (`fpgm`, `prep`, `cvt `, glyph programs), in safe code, with the rules of backward-compatible rendering as DirectWrite does for grayscale text: only y positions move, `GETINFO` reports interpreter version 40, and no point moves after both `IUP` instructions have run. - **What changes.** Drawn outlines (`DrawText`, `TextBuilder.GeneratePath`) only. Measuring, advances, wrapping and x positions are exactly what `None` gives, so layout never depends on the hinting mode. Composite glyphs are hinted per component; the composite's own instructions are not run. - **When it is skipped** (the unhinted outline is used): CFF fonts, fonts without instructions, `Path` text, vertical modes, super- and subscript runs, sizes below 4 or above 400 px per em, and any glyph or font program that fails. - **Safety.** Font programs are untrusted. Budgets: 2^20 instructions for `fpgm`/`prep`, 2^18 per glyph program and 2^20 in total for all the programs of one top-level glyph (a composite's components share it, so a composite of any size costs at most 2^20 interpreter steps; when the total runs out the whole glyph is drawn unhinted, including components already fitted), call depth 32, stack, storage, CVT, twilight points and function counts clamped to `maxp` and fixed caps, `SLOOP` and `LOOPCALL` counts paid for in instructions, bounds-checked indexes, no function definitions in glyph programs. All arithmetic on font-controlled values is overflow-safe (no `Math.Abs` on the most negative integer, 128-bit intermediates in `ISECT`). The twilight zone is kept as `prep` leaves it, and each glyph program starts from its own copy, so points `prep` placed are visible to glyph programs and one glyph's changes never reach the next. Any violation aborts that program: the glyph (or the whole font at that size, for a failing setup program) is drawn unhinted, and nothing throws. Hinters and hinted outlines are cached per face and size (16 sizes, 8192 glyphs) and safe to use from many threads. - **Agreement.** Black text on white at 9-16 px (24 cases: three strings, eight sizes) from a Latin subset of Noto Sans (ttfautohint instructions) against SixLabors.Fonts 2.1.3 with `HintingMode.Standard`: mean absolute level difference 0.45 (worst case 0.79), against 1.47 for our unhinted text and 1.49 between the reference's own hinted and unhinted output; hinting moves us closer in 24 of 24 cases. With a hand-hinted system font (DejaVu Sans or Liberation Sans, not committed) one exploratory run gave 0.70 against 1.40. Exact agreement is not expected for hand-hinted fonts. - **Not supported:** other rendering modes (ClearType-style x hinting, the native ClearType mode of interpreter version 35) and hinting of rotated text. ## Rich text: runs, decorations and attributes `TextOptions.TextRuns` gives parts of the text their own look. A `TextRun(start, end)` covers UTF-16 offsets `[start, end)` of the string passed to the call and can set: | Property | Default | Effect | |---|---|---| | `Font` | null: `TextOptions.Font` | Face and size of the run. A run can use any family and style. | | `TextAttributes` | `None` | `Superscript` or `Subscript` (below). | | `TextDecorations` | `None` | Any of `Underline`, `Strikeout`, `Overline`. | | `Brush`, `Pen` | null: the brush and pen of the `DrawText` call | Paint of the run's glyphs and of its filled decorations. | | `UnderlinePen`, `StrikeoutPen`, `OverlinePen` | null | Strokes that decoration as a line with this pen (its width is the thickness) instead of filling a rectangle. | ```csharp var options = new TextOptions(regular24) { Origin = new(20, 20), WrappingLength = 500 }; options.TextRuns.Add(new TextRun(0, 5) { Font = bold40, Brush = Brush.Solid(red), TextDecorations = TextDecorations.Underline }); options.TextRuns.Add(new TextRun(12, 13) { TextAttributes = TextAttributes.Superscript }); image.DrawText("Hello, rich world", options, Brush.Solid(black)); ``` - **Resolution.** A character belongs to the last run in the list that covers it, otherwise to no run (the options' font, no decoration, the call's paint). Ranges are clamped to the text and empty ones ignored, so the work is linear in the text plus the number of runs however runs overlap. A run is read when the layout runs; `Clone()` copies runs. - **Mixed sizes and faces.** Shaping, kerning and ligatures stay inside a stretch of one run and face. Each glyph carries its own scale, so widths, wrapping and alignment mix sizes freely. A line is as tall as the largest line pitch (`size × dpi/72 × LineSpacing`) of any glyph on it, and its baseline sits at the largest "ascent in the line box" (the font's ascent, with the leading split evenly above and below as described under *Units and layout*). Lines stack without gaps; an empty line takes the metrics of the run at its position. `LineMetrics` reports the line's own `Ascender`, `Descender` and `LineHeight`. A tab is `TabWidth` spaces of the space glyph of the run it sits in. - **Decorations.** Positions come from the font of each glyph: the underline at `post` underline position and thickness, the strikeout at `OS/2` strikeout position and size, the overline one em plus the underline distance above the baseline with the underline's thickness (fonts with no such data get 1/20 em thick lines, an underline 1/10 em below and a strikeout 3/10 em above the baseline). The measures are the centers of the lines. A decoration runs from the pen position of the first decorated glyph to the end of the advance of the last one, as one rectangle across neighbouring glyphs of the same run, position and thickness, so it is continuous across glyphs, spaces and kerning; it restarts when the font or size changes, ends at the last visible glyph of each line (trailing spaces at a wrap are not underlined) and starts again on the next line. Decorations are painted like their run's glyphs, in the same path, and are left out of `Bounds` and ink boxes. - **Super- and subscript.** The glyphs are scaled by the font's `OS/2` superscript or subscript size (x and y separately, by default 65 % by 60 % of the em), advances scale with them, and the baseline moves by the recommended y offset scaled by the vertical size (up for superscript, down for subscript). The line pitch of such a run is scaled by the vertical size too. Horizontal offsets are not applied. Decorations on such a run sit on the shifted baseline with scaled thickness. - **Painting.** `DrawText` draws one path per distinct brush/pen pair (runs that share a brush and pen share a path), so a text with default paint is still one fill. `TextBuilder.GeneratePath` returns everything as one path, decorations as rectangles (a decoration stroked with its own pen has no outline and is left out). `DrawText` with every run painted accepts a null brush and pen. - **Limits.** At most `TextOptions.MaxTextRuns` (16,384) runs; more throw `ImageLimitException`. Layout cost does not depend on how runs overlap; one style object is created per (run, face) actually used. ## Font fallback `TextOptions.FallbackFontFamilies` lists families tried, in order, for characters the run's font cannot map. Up to `MaxFallbackFamilies` (32); more throw `ImageLimitException`. - **Choosing.** For each grapheme cluster the first code point (zero-width and format characters and tabs never choose) is looked up in the run's face, then in the face of each fallback family that matches the run's style (or is closest to it), at the run's size. The first face with a glyph (a nonzero glyph id in its character map) shapes the whole cluster, so a base and its combining marks stay together. When no face has it, the primary face draws its missing-glyph box. - **Shaping.** A stretch of one face is one shaping run, so ligatures and kerning never cross a change of face, and line breaking and wrapping work on the shaped glyphs as before. - **Metrics.** A line takes its baseline from the largest ascent of the faces used on it, so a line with a fallback face of a taller ascent sits lower than the same line without it (the Noto Sans CJK subset, with an hhea ascent of 1.16 em against Noto Sans's 1.069 em, moves a line by 1.92 px at 40 px). Line height is the largest pitch (size-based), so it does not grow with a fallback face. Each face's own `OS/2`/`hhea` rule picks its ascender (typographic metrics when `USE_TYPO_METRICS` is set). - **Cost.** One character-map lookup per cluster per face tried, only when fallback families are given. ## Text on a path `TextOptions.Path` lays the text along a `Path`. The text is laid out exactly as straight text would be (wrapping, alignment, line spacing, origin, runs, fallback), treating the path as the top edge of the first line, and each glyph is then moved onto the path: - A glyph's anchor is the middle of its advance. Its **distance** along the path is the layout x of the anchor (origin and alignment included), so `HorizontalAlignment.Center` centers about the *start* of the path, not its middle, and an origin x moves text along it. - The glyph is **rotated about its anchor** to the direction of the path at that distance and is not bent: a rigid move per glyph. Its offset from the path is its layout y, to the right of the direction of travel in a y-down image (so text on a left-to-right line stands below it, and a clockwise circle carries text on the inside). Later lines are further to the right; `VerticalAlignment` and `Origin.Y` move the whole block off the path. - **Past the ends.** An open path continues straight: before the start along its first segment, after the end along its last. A closed path wraps around: glyphs beyond the perimeter start over at the beginning, and the closing edge is a real edge. - **Which path.** The path is flattened (0.1 px) and the first contour with a length is used; a path without length throws `ArgumentException`. Measuring (`TextMeasurer`) ignores the path. - **Decorations** follow the path glyph by glyph (each piece is a rectangle rotated with its glyph), so on a curve neighbouring pieces overlap on the inside and open a thin wedge on the outside. - **Cost.** One flatten, then a binary search over the cumulative segment lengths per glyph: linear in glyphs, logarithmic in path points. 100,000 glyphs on a path of 200,000 points take about 110 ms. ## System fonts `SystemFonts` finds installed fonts without native calls: it scans the standard directories (Windows: the system Fonts folder and `%LOCALAPPDATA%\Microsoft\Windows\Fonts`; macOS: `/System/Library/Fonts`, `/Library/Fonts`, `/Network/Library/Fonts`, `~/Library/Fonts`; elsewhere: `/usr/share/fonts`, `/usr/local/share/fonts`, `$XDG_DATA_DIRS/*/fonts`, `~/.fonts` and `$XDG_DATA_HOME/fonts` or `~/.local/share/fonts`). `SystemFonts.Installed` is the process-wide `SystemFontSet`; `new SystemFontSet(directories)` scans directories of your choice. ```csharp Font font = SystemFonts.CreateFont("DejaVu Sans", 24, FontStyle.Bold); IReadOnlyList names = SystemFonts.FamilyNames; // sorted, nothing loaded ``` - **Lazy.** Nothing happens until the first lookup. The first call that needs names lists the files (`.ttf`, `.otf`, `.ttc`, `.otc`, to a depth of 8, at most 100,000 files) and reads just the table directory and `name` table of each, never glyph data: 10 ms for directories holding 1,413 files (277 families) on a warm cache on the Linux host this was built on. The result is kept for the life of the set; fonts installed afterwards are not noticed. - **Loading.** `TryGet`/`Get`/`CreateFont` parse the files of that one family (all its styles) the first time and return the same `FontFamily` afterwards. A file that no longer parses is skipped; its family may then be missing or partial. WOFF files are not scanned (their tables are compressed). - **Safe.** Header reads are bounds-checked against the file length and stop at 256 faces per collection, 256 tables and 1 MiB of names; unreadable directories and files are skipped without an error. Family names are the ones `FontFamily.Name` gives (the typographic family when the font has one). ## Drawing `DrawText(text, options, brush, pen, ...)` exists for `Image` (lazy: layout happens at the call, pixels when the image is read) and `Canvas` (in place), plus overloads taking a color. The optional parameters are the fill ones: `antialias`, `opacity`, `blend`, `composition`, `linear`. With both a brush and a pen the outline is drawn over the fill. Glyph outlines are filled with the non-zero rule. `TextBuilder.GeneratePath` returns the same path as an ordinary `Path`, for filling, stroking or transforming yourself. ## Agreement with ImageSharp Checked against SixLabors.Fonts 2.1.3 and ImageSharp.Drawing 2.1.7 through their public APIs (tests in `tests/OneImage.Text.Tests`). - Glyph outlines: the same contours and points (TrueType, CFF-CID and composite glyphs) to within 0.01 font unit. The reference starts contours at a different point and inserts zero-length lines; the comparison normalizes both. - Advances and kerning: equal to 0.01 px at one pixel per font unit, for GPOS kerning, the `kern` table and CFF with GPOS. - Line counts, advance size and ink bounds: equal (0.01 and 0.05 px) across sizes, dpi, wrapping lengths, line spacing and all alignment combinations in the test matrix. - Rendering: black text on white, mean level difference 0.03 to 0.28 over the whole image (3 to 5 over pixels where either draws ink), maximum 27 to 106 levels at single edge pixels. Positions are identical; the rest is coverage. We compute exact-area coverage; ImageSharp's scanline rasterizer over- and under-covers some thin or touching edges (see [drawing.md](https://recursiveai.org/oneimage/docs/drawing/)). Differences you will meet, all deliberate or pending the later units: - **Ligatures and wrapping.** We shape first and wrap on the shaped glyphs, so a line holding `fi`, `ffl` and the like is measured with the ligature glyph's own advance. ImageSharp substitutes the same ligatures but over-measures a line that holds one when it decides where to wrap (60 random 8-word texts with `fi`/`ff`/`fl` words: the line count differs in 33; the same texts without those letters: in none). A single line's advance is equal to 0.0001 px with and without ligatures. - **Feature order and `numr`/`dnom`.** Lookups run in lookup-list order, so `smcp` before `liga` (above). ImageSharp leaves the digits alone when asked for `numr` or `dnom` on Noto Sans; we substitute the font's numerator and denominator forms, as the font defines them. Everything else we compared (ligatures, `ccmp` compositions, marks and stacked marks in Latin, Greek and Cyrillic, `smcp`, `c2sc`, `onum`, `lnum`, `tnum`, `pnum`, `zero`, `frac`, `sups`, `subs`, `ordn`, `case`, `ss03`, `ss04`, `salt`, `aalt`) gives the same glyph ids and origins to 0.002 font units. - **Language** cannot be chosen in ImageSharp 2.1.3's `TextOptions`, so language forms (`locl`) are checked against the font's own tables, not against it. - **Wrapping against ImageSharp.** Ordinary text (words separated by spaces, with the usual punctuation, quotation marks, brackets, numbers with separators, no-break spaces, Greek and Cyrillic) wraps at the same places in `Standard` and `KeepAll`: 1,590 combinations of five texts, three sizes and 53 wrapping lengths, none different; `BreakAll` and `BreakWord` agree in 95% and 91% of them. Where opportunities are not after spaces the two part, for these reasons (each pinned by a test in `tests/OneImage.Text.Tests/WrapReferenceTests.cs`): - ImageSharp fills a line exactly when it ends at a space, but after a hyphen, a soft hyphen, a zero width space or an ideograph it also wants room for the next character, so it ends the line one opportunity early: with "mm-mm-mm" it needs the width of one more "m" (18.75 px at 20 px) than the line occupies; CJK text holds one character fewer per line. We fill exactly: a line is accepted when its text fits. This explains most (83%, against 57% for an exact fill) of the differences on hyphenated, soft-hyphenated, zero-width-space and CJK text; the rest is how ImageSharp treats soft hyphens and some hyphen cases. - ImageSharp ends a line between a prefix or sign and its number ("$ | 1,234.56", "- | 5.25"); UAX #14 rule LB25 keeps them together, as do we. - ImageSharp implements an older revision of the line breaking rules (UAX #14 revision 37). Under rules LB15a-d, added since, an opening quotation mark stays with the word after it even across spaces: "word “ quoted" wraps as "word" then "“ quoted" here, and as "word “" then "quoted" there. - ImageSharp never breaks after a solidus, so a long URL stays on one line; we break after "/" before a letter as UAX #14 allows, which wraps URLs and paths. - `KeepAll` in ImageSharp stops wrapping after the first line that cannot be broken to fit, and its `BreakWord` starts a too-long word on the current line; ours moves it to a fresh line first. - **Tabs** advance `TabWidth` spaces. ImageSharp's `TabWidth` had no effect on the font we probed, which has no tab glyph. - **Empty lines.** Text that starts with a line break measures two lines of one pitch each; ImageSharp adds extra height for the leading empty line. A line of only spaces keeps its full width here; ImageSharp reported one space's width for two. Text with no ink has zero-size bounds, where ImageSharp reports a box. - **Color glyph advances.** We use the base glyph's advance, as the OpenType specification and the layout of every other glyph do. With color enabled SixLabors.Fonts 2.1.3 advances by the advance of the glyph's first layer instead; the two agree in fonts whose layers are as wide as their base glyph (the test font), and differ in others. - **Color rendering** on the test font (layered shapes, foreground color, a half-transparent layer, two palettes, a composite layer): brush only, mean 0.07 levels over the image and at most 28 at single edge pixels; with a pen, mean 1.9 to 2.7 levels, which is the half-pixel stroke placement difference described in [drawing.md](https://recursiveai.org/oneimage/docs/drawing/). COLR version 1 paint graphs are not painted by SixLabors.Fonts 2.1.3; see [COLR version 1](#colr-version-1) for the comparison used instead. - **WOFF2** has no ImageSharp difference worth naming: faces, outlines (point for point), advances, side bearings, kerning and shaped text from the WOFF2 of each test font (TrueType, CFF, a collection, and a file using the `hmtx` transform) equal those of the source font. - **Rich text** (probed through the public API of SixLabors.Fonts 2.1.3 and ImageSharp.Drawing 2.1.7; tolerances are what the tests assert): - *Runs with mixed sizes and faces*, with and without wrapping: glyph ink boxes agree to 0.0002 px, advance and line count exactly. In some run configurations (a run without a font next to plain text, or runs of different sizes) the reference applies an extra pair adjustment to `r` followed by some letters (-20 units) and, in one probed case, to `o` followed by `w` (-60 units; in mixed-size text it is scaled by the size of one of the runs) that it does not apply to the same text without runs; we apply the font's `GPOS` kerning only, and the tests use text without the pairs. - *Overlapping runs*: the reference drops the rest of an outer run after a nested one (an inner run in the middle of an outer run leaves the outer run's decorations off the text after it); we resolve every character to the last run covering it. - *Decorations*: segment ends and heights agree to 0.01 px, thickness to 0.01 px, in plain, wrapped, mixed-size and raised runs. The reference reports one segment per glyph; we report one per line and run, and the filled rectangle is the same area. - *Super- and subscript*: ink boxes agree to 0.0001 px. - *Fallback without wrapping*: ink boxes agree exactly. *With wrapping* the line count agrees but the reference declines some break opportunities where a fallback face is involved (for `ABあいCD` at 150 px it keeps two kana on a line where the text fits three); we wrap by the same rule as without fallback. - *Text on a path*: glyph vertices agree to 0.0003 px on lines (horizontal, diagonal, reversed, steep), alignments, origin, wrapping and multi-line text, and past the end of an open path. On a 120-segment circle ten of twelve glyphs agree to 0.001 px; two sit one segment step (up to 1.9 px at their far corners) away from the reference's. The reference does not place glyphs on the closing edge of a closed polygon (they continue straight past the last explicit vertex) while still wrapping at the full perimeter; we follow the closing edge. We also take the first contour of a multi-contour path. - *Rendering* (black on white, 420x200, mean absolute level difference over all pixels; `RichRenderTests`): runs with underline, strikeout and overline 0.62 (max 248), a wrapped paragraph with a larger run and superscript 0.39 (max 254), fallback text 0.13 (max 133), text along a polyline 0.07 (max 99). Glyph pixels agree like plain `DrawText` does. The large maxima are decoration lines: both libraries agree on where a decoration is (segment ends, center and thickness within 0.01 px) but the reference rasterizes the thin rectangle about 0.3 to 0.7 px higher or lower than that geometry, so a one or two pixel line differs in whole rows; we paint the reported geometry exactly. - *Layout and hinting modes*: the reference's `HorizontalBottomTop` mode places glyphs as top-to-bottom does; ours stacks lines upward. Vertical layout and hinting are compared in [Vertical layout](#vertical-layout) and [Hinting](#hinting). ## Shaping clusters for your own layout `TextShaping` (in `OneImage.Text`) exposes what a caller needs to lay text out itself — SVG text, with a position for every character, is the user — without the library's own paragraph layout: ```csharp byte[] levels = TextShaping.GetBidiLevels(text, TextDirection.LeftToRight, out int paragraphLevel); // UAX #9 level per UTF-16 unit int[] visual = TextShaping.GetVisualOrder(levels); // logical indexes in visual order IReadOnlyList clusters = TextShaping.ShapeRun(text, font, rightToLeft: false, new ShapingOptions { Language = "tr" }); foreach (ShapedCluster c in clusters) { // c.Start and c.Length: UTF-16 units of the input; c.Advance: pixels at the font size, kerning included; // c.GetOutline(): the glyphs as a Path with the pen at (0, 0), y down, in pixels; c.HasMissingGlyph. } ``` - `GetBidiLevels(string, TextDirection, out int paragraphLevel)` runs the algorithm on one paragraph (`Auto` picks the level from the first strong character) and returns one level per UTF-16 unit. `GetVisualOrder(ReadOnlySpan levels)` reverses runs per rule L2 and returns the logical index at each visual position; an empty input gives an empty array. - `ShapeRun(ReadOnlySpan, Font, bool rightToLeft = false, ShapingOptions? = null)` shapes one run in one font and returns its clusters in logical order. A cluster is what cannot be positioned apart (a letter with its marks, a ligature, a syllable), so each can be placed, rotated or stretched on its own. Runs longer than `TextShaping.MaxRunLength` (2^24) throw `ArgumentException`. The run is not split by script or direction: the caller does that, and passes `rightToLeft` for odd levels. A character the font lacks gives a cluster with `HasMissingGlyph` true and the font's missing-glyph outline. - `ShapingOptions`: `Language` (BCP 47, default the font's default language system), `Kerning` (`KerningMode`, default `Normal`) and `Features` (`FontFeature` list, empty by default, applied on top of the script's defaults; value 0 switches a feature off). - **Vertical columns.** `ShapingOptions.Vertical` shapes a run to stand upright in a column (kerning off, `vert` and `vrt2` on, like the vertical `LayoutMode`s; `Features` still win). `ShapedCluster.VerticalAdvance` is the cluster's height in a column (the sum of `vmtx` advances, or the line height without vertical metrics) and `GetUprightOutline()` its outline with the origin at the middle of the column at the top of the cell, placed by `VORG` or the top side bearing exactly as the vertical layout modes place upright glyphs. `TextShaping.IsUprightInVerticalText(codePoint)` is the UAX #50 test (U and Tu stand; the rest lie rotated a quarter turn clockwise, which a caller does with `GetOutline()` and `Advance`). - `ShapedCluster.GetOutline()` builds the outline once and returns the same `Path` afterwards. Outlines are unhinted. Nothing is cached across runs. - `FontFaceInfo.GetWeight(Font)`, `GetIsItalic(Font)`, `GetSuperscriptRaise(Font)` and `GetSubscriptDrop(Font)` read the face's OS/2 weight class, italic flag and the superscript and subscript offsets in pixels at the font's size (0 when the face has none). ## Where the next pieces plug in `Layout/TextLayout.cs` is the whole layout in four steps with narrow seams between them. - **Shaping** is `Shape` calling OneFont's `TextShaping.ShapeCodePoints`: code points and the clusters the layout chose in, glyphs (glyph id, cluster, code point, advance with kerning, offsets) out. Script-specific shapers live in OneFont. - **Line breaking** is `PrepareBreaks` and `CanBreakBefore`: the break opportunities and cluster starts of a paragraph are computed from its text (OneFont's `TextSegmentation`) and reach the glyphs only through each glyph's text offset, so a shaper that merges or reorders glyphs needs no change here. Hyphenation, language-tagged tailoring and dictionary breaking for Thai, Lao, Khmer and Burmese (their characters are treated as letters, so there is no break inside a word) would be new work in OneFont. - **Color fonts** beyond COLR (SVG and bitmap glyphs) extend the color branch of `BuildParts`, which already turns the layers of a version 0 color glyph into one fill per layer index and color, and hands a version 1 glyph to `ColrPainter`. - **Runs, fallback, decorations and paths** live in `Layout/TextLayout.cs`: `ResolveRuns` maps every offset to a run, `Shape` splits shaping runs by (run, face) and every `ShapedGlyph` carries a `GlyphStyle` (face, scale, shift, line and decoration measures); `BuildParts` writes outlines and decoration rectangles per paint, with `GlyphMatrix` giving the straight or on-path placement (`Layout/PathRoute.cs`). ## ImageSharp names | SixLabors.Fonts / ImageSharp.Drawing | OneImage.Text | |---|---| | `FontCollection.Add(path / stream)`, `AddCollection` | `FontCollection.Add(path / stream / memory)`, `AddCollection` | | `FontFamily.CreateFont(size, style)` | `FontFamily.CreateFont(size, style)` | | `RichTextOptions` / `TextOptions` | `TextOptions` (`Origin`, `WrappingLength`, `LineSpacing`, `TabWidth`, `Dpi`, alignments, `Kerning`, `WordBreaking`, `TextJustification`, `Language`, `Features`, `TextRuns`, `FallbackFontFamilies`, `Path`) | | `RichTextRun` / `TextRun` (`Start`, `End`, `Font`, `TextAttributes`, `TextDecorations`, `Brush`, `Pen`, `UnderlinePen`, `StrikeoutPen`, `OverlinePen`) | `TextRun` with the same members (`Start`/`End` are UTF-16 offsets, End exclusive) | | `SystemFonts.Get` / `CreateFont` / `Families` | `SystemFonts.Get` / `CreateFont` / `FamilyNames` (names only until a family is requested) | | `TextBuilder.GenerateGlyphs(text, path, options)` | `TextOptions.Path` with `TextBuilder.GeneratePath` or `DrawText` | | `TextOptions.FeatureTags` (`Tag`) | `TextOptions.Features` (`FontFeature`, with a value and off switches) | | `TextMeasurer.MeasureAdvance` / `MeasureBounds` / `MeasureSize` / `CountLines` | `TextMeasurer.MeasureAdvance` / `MeasureBounds` / `Measure(...).Bounds.Size` / `CountLines` | | `TextMeasurer.TryMeasureCharacterBounds` | `TextMeasurer.GetCharacterBounds` (`Ink`) | | `ctx.DrawText(options, text, brush, pen)` | `img.DrawText(text, options, brush, pen)` / `canvas.DrawText(...)` | | `TextOptions.ColorFontSupport` (`MicrosoftColrFormat`) | `TextOptions.ColorFontSupport` (`MicrosoftColrFormat` by default; also `ColorPaletteIndex`) | | `TextOptions.LayoutMode` | `TextOptions.LayoutMode` (same members) | | `TextOptions.HintingMode` (`None`, `Standard`) | `TextOptions.HintingMode` (`None` default, `Standard`) | | `TextBuilder.GenerateGlyphs` | `TextBuilder.GeneratePath` | | `TextRenderer` + `IGlyphRenderer` | `TextBuilder.GeneratePath`, then walk or transform the `Path` | # SVG Design of `RecursiveAI.OneImage.Svg` (assembly and namespace `OneImage.Svg`): SVG as an image format that renders directly at the size you ask for. This page describes what the package is, how it is built, and where it stands. The package detects SVG, reads its natural size and draws shapes, paths, strokes, gradients, patterns, markers, groups with opacity and blend modes, transforms, viewports, `use` and `symbol`, clip paths, masks, style sheets, `switch`, `data:` images, filters and text at any size and one region at a time. ```csharp using OneImage; using OneImage.Svg; var config = new ImageConfiguration().Add(SvgFormat.Instance); // not part of ImageConfiguration.Default, so it trims away when unused var load = new LoadOptions { Configuration = config }; ImageInfo info = Image.Identify("logo.svg", load); // natural size, nothing rendered Image.Load("logo.svg", load).Resize(width: 400).Save("logo-400.png"); // rasterized at 400 px, not scaled up from the natural size ``` ## Scope Static SVG 1.1 and the SVG Tiny-style content found in practice, plus the parts of SVG 2 and CSS that documents rely on: | Area | Supported | |---|---| | Structure | `svg` (nested too), `g`, `defs`, `use`, `symbol`, `switch` (`systemLanguage`, `requiredFeatures`), `a`, `title`/`desc` ignored | | Shapes | `rect` (with `rx`/`ry`), `circle`, `ellipse`, `line`, `polyline`, `polygon`, `path` | | Painting | fill and stroke (width, caps, joins, miter limit, dashes), `fill-rule`, opacities, `paint-order`, `vector-effect: non-scaling-stroke`, `shape-rendering`, `visibility`, `display`, `color`/`currentColor`, `context-fill`/`context-stroke`, `mix-blend-mode`, `isolation`, `opacity` | | Paint servers | `linearGradient`, `radialGradient` (with focal point and `fr`), `pattern`, href inheritance, `gradientTransform`/`patternTransform`, `spreadMethod`, both unit systems | | Clipping and masking | `clipPath` (nested, per-child `clip-rule`), `mask` (luminance and alpha, nested), `overflow` clipping of nested viewports and markers | | Markers | `marker-start`/`mid`/`end` and the `marker` shorthand, `orient`, `markerUnits`, `viewBox`, `refX`/`refY` | | Images | `image` with a `data:` URI (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO and nested SVG, decoded by OneImage's own codecs), `preserveAspectRatio`, `image-rendering` | | Text | `text`, `tspan`, `textPath`, per-character `x`/`y`/`dx`/`dy`/`rotate`, `text-anchor`, baselines, decorations, spacing, `textLength`, writing modes; rendered through `OneImage.Text` | | Filters | the `filter` element with the full SVG 1.1 primitive set, `feDropShadow`, and the CSS filter functions (`blur()`, `drop-shadow()`, `grayscale()`, ...) | | CSS | presentation attributes, the `style` attribute, `