Table of Contents

Drawing

Egami.Drawing is a separate package that draws antialiased paths, shapes, strokes, clips and images on a Canvas. It is at 1.0 (1.0.0) and its API is stable: 1.x versions add to it without breaking code that uses it. Text is drawn with Egami.Fonts; see Fonts and text.

A canvas

raster.CreateCanvas() returns a RasterCanvas<TPixel> that draws on the raster's first frame, in the pixel type's own precision. Calls draw when they are made; Save and Restore bracket changes to the transform and clip.

// A canvas draws onto a raster. Calls draw when they are made; dispose the canvas when done.
using Raster<Rgba8> raster = Raster.Create(400, 300, Rgba8.White);
using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas())
{
    canvas.FillRect(new RectF(20, 20, 160, 100), new Rgba8(30, 90, 200, 255));

    // Save and Restore bracket transform and clip changes.
    canvas.Save();
    canvas.Translate(260, 150);
    canvas.Rotate(30);
    canvas.FillRect(new RectF(-50, -30, 100, 60), new Rgba8(200, 60, 30, 200));
    canvas.Restore();

    canvas.FillCircle(new Vector2(100, 220), 50, Rgba8.Black);
}

// Blend edges in linear light, which keeps thin lines and translucent colors from darkening.
using RasterCanvas<Rgba8> linear = raster.CreateCanvas(new CanvasOptions { ColorSpace = ColorSpace.Linear });
  • Coordinates are float pixels, y down. Pixel (0, 0) covers the square from (0, 0) to (1, 1), so a RectF with whole-number edges covers whole pixels and a rectangle from 2.5 covers pixel 2 by half.
  • Every pixel gets its exact covered area, rounded to 1/255: abutting shapes leave no seams.
  • CanvasOptions sets where edges blend (ColorSpace.Encoded, the default, or Linear) and the curve Tolerance (0.1 pixel by default).
  • CanvasOptions.PixelOffset and CanvasOptions.Antialiasing switch to GDI+'s pixel centres and antialiasing, for images that must look like System.Drawing's (see Matching System.Drawing).
  • A canvas is for one thread at a time. A large call spreads its rows over EgamiContext.MaxParallelism cores, with the same pixels at any core count.

Shapes

canvas.FillRect(new RectF(10, 10, 80, 40), Rgba8.Red);
canvas.FillRoundRect(new RoundRect(new RectF(100, 10, 80, 40), 12), new Rgba8(0, 128, 0, 255));

// Each corner can have its own radii; radii that would overlap are scaled down together, as CSS does.
CornerRadii tab = new(topLeft: 16, topRight: 16, bottomRight: 0, bottomLeft: 0);
canvas.FillRoundRect(new RoundRect(new RectF(190, 10, 100, 40), tab), Rgba8.Black);

canvas.FillOval(new RectF(10, 60, 120, 60), new Rgba8(255, 200, 0, 255));
canvas.FillCircle(new Vector2(170, 90), 30, new Rgba8(80, 80, 200, 255));
canvas.FillArc(new RectF(210, 60, 60, 60), startDegrees: -90, sweepDegrees: 120, Rgba8.Red);
canvas.FillRing(new RoundRect(new RectF(10, 130, 60, 60), 30), new RoundRect(new RectF(25, 145, 30, 30), 15), Rgba8.Black);

// The same shapes as paths, to build on or to reuse.
Path hexagon = Path.RegularPolygon(new Vector2(120, 160), 30, sides: 6);
Path star = Path.Star(new Vector2(200, 160), outerRadius: 30, innerRadius: 12, points: 5);
canvas.FillPath(hexagon, new Rgba8(0, 150, 150, 255));
canvas.FillPath(star, new Rgba8(220, 160, 0, 255));

Each shape has a Fill and a Stroke call. RoundRect takes CornerRadii, one x and y radius per corner; its Kind (RoundRectKind) says when it is really a rectangle or an ellipse. FillRing fills between two round rectangles.

Paths

A Path holds contours of lines, quadratic and cubic curves, built with a PathBuilder, and never changes once built. Arcs, ovals and round corners become cubic curves as they are added. PathVerb names each command and Points holds their points, for code that reads paths back.

// A path is built once and never changes, so it can be drawn many times and from many threads.
Path heart = new PathBuilder()
    .MoveTo(150, 60)
    .CubicTo(150, 30, 100, 20, 90, 60)
    .CubicTo(80, 100, 130, 130, 150, 160)
    .CubicTo(170, 130, 220, 100, 210, 60)
    .CubicTo(200, 20, 150, 30, 150, 60)
    .Close()
    .ToPath();
canvas.FillPath(heart, Rgba8.Red);

// SVG's arc command, and the even-odd rule: an inner contour becomes a hole.
Path frame = new PathBuilder()
    .AddRect(new RectF(10, 10, 60, 60))
    .AddCircle(new Vector2(40, 40), 20)
    .MoveTo(10, 120)
    .ArcTo(radii: new Vector2(30, 20), rotationDegrees: 0, largeArc: false, clockwise: true, end: new Vector2(70, 120))
    .Close()
    .ToPath(FillRule.EvenOdd);
canvas.FillPath(frame, Rgba8.Black);

bool hit = heart.Contains(new Vector2(150, 100));   // hit testing on the curves themselves
RectF box = heart.TightBounds;                       // curves measured at their extremes
  • FillRule decides what is inside: NonZero (the default) or EvenOdd. Shapes are added clockwise; a PathDirection.CounterClockwise contour inside a clockwise one makes a hole under NonZero too.
  • Contains uses the winding number on the curves themselves, and counts a point on the outline as inside.
  • Transform takes a Matrix3x2, or a Homography for perspective.

Strokes

Stroke is the pen: Width, Cap (LineCap.Butt, Round, Square), Join (LineJoin.Miter, Round, Bevel) and MiterLimit (4). The pen is applied in local coordinates, so a non-uniform scale gives an elliptical pen.

// The pen is data: width, caps, joins and miter limit. Width 1, butt caps and miter joins by default.
Stroke thick = new(12) { Cap = LineCap.Round, Join = LineJoin.Round };
canvas.DrawLine(new Vector2(20, 20), new Vector2(280, 20), thick, new Rgba8(30, 90, 200, 255));

Path zigzag = Path.Polygon([new(20, 120), new(80, 60), new(140, 120), new(200, 60), new(260, 120)], close: false);
canvas.StrokePath(zigzag, new Stroke(8) { Join = LineJoin.Miter, MiterLimit = 4 }, Rgba8.Black);

// A hairline is one device pixel wide whatever the transform.
canvas.StrokeCircle(new Vector2(150, 160), 30, Stroke.Hairline, Rgba8.Red);
  • Width 0 is a hairline: one device pixel across its major axis at any scale, as in Skia. A stroke thinner than a pixel draws as a hairline, fainter by its width.
  • Overlaps within one stroke never double the color.
  • DrawPoints takes a PointMode: dots, pairs of points as lines, or one polyline.

Paints

A color converts to a SolidPaint wherever a Paint is expected. The paint also carries a BlendMode, an Opacity and Antialias.

// A color converts to a SolidPaint. A paint also carries a blend mode, an opacity and whether edges are antialiased.
canvas.FillRect(new RectF(0, 0, 200, 100), Rgba8.White);
SolidPaint glaze = new(new RgbaF32(0.9f, 0.2f, 0.1f, 1)) { BlendMode = BlendMode.Multiply, Opacity = 0.6f };
canvas.FillCircle(new Vector2(60, 50), 40, glaze);

// Pixel art: no antialiasing, so every pixel is fully drawn or not at all.
canvas.FillCircle(new Vector2(150, 50), 30, new SolidPaint(Rgba8.Black) { Antialias = false });

Gradients

LinearGradient, RadialGradient, ConicalGradient (between two circles, as the HTML canvas, Skia and PDF define it) and SweepGradient take GradientStops from 0 to 1. Tile says what lies past the ends (Clamp, Repeat, Mirror, Decal), and with Clamp, ExtendStart and ExtendEnd can leave one side empty, as a PDF shading's Extend does. Stops mix in encoded sRGB by default, or in linear light, Oklab or Oklch with a HueMethod (GradientInterpolation, whose InterpolationSpace names the space), as CSS Color 4 defines them, and premultiplied so a fade to a transparent color never darkens. Every paint has a LocalMatrix, which turns a radial gradient into an ellipse. On 8-bit targets a gradient reads 1024 colors made once per paint; Dither adds a 4×4 ordered pattern that hides banding on large soft ramps.

using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas())
{
    // Two colors along a line; past the ends the end colors repeat (TileMode.Clamp).
    canvas.FillRect(new RectF(0, 0, 200, 100), new LinearGradient(new Vector2(0, 0), new Vector2(200, 0), new RgbaF32(0.1f, 0.3f, 0.9f, 1), new RgbaF32(1, 0.8f, 0.2f, 1)));

    // Several stops, mixed in Oklab for even midpoints, repeating every 40 pixels.
    canvas.FillRect(new RectF(200, 0, 200, 100), new LinearGradient(
        new Vector2(200, 0),
        new Vector2(240, 0),
        new GradientStop(0, new Rgba8(230, 40, 60, 255)),
        new GradientStop(0.5f, new Rgba8(250, 210, 60, 255)),
        new GradientStop(1, new Rgba8(230, 40, 60, 255)))
    {
        Tile = TileMode.Repeat,
        Interpolation = GradientInterpolation.Oklab,
    });

    // A radial gradient stretched into an ellipse by its local matrix, and a sweep around a center.
    canvas.FillOval(new RectF(10, 110, 180, 80), new RadialGradient(Vector2.Zero, 1, new RgbaF32(1, 1, 1, 1), new RgbaF32(0, 0.4f, 0.2f, 1))
    {
        LocalMatrix = Matrix3x2.CreateScale(90, 40) * Matrix3x2.CreateTranslation(100, 150),
    });
    canvas.FillCircle(new Vector2(300, 150), 45, new SweepGradient(
        new Vector2(300, 150),
        new GradientStop(0, new Rgba8(255, 0, 0, 255)),
        new GradientStop(0.5f, new Rgba8(0, 0, 255, 255)),
        new GradientStop(1, new Rgba8(255, 0, 0, 255))));
}

Images, noise and your own paints

ImagePaint fills with an image under its local matrix, tiled per axis, sampled by Egami's filters and from a MipChain level when shrunk. NoisePaint.Fractal and NoisePaint.Turbulence (its NoisePaintKind) are SVG's feTurbulence (matching SkiaSharp's noise shaders), and NoisePaint.Simplex is smoother and tiles exactly. BlendPaint combines two paints with a blend mode, WithColorMatrix runs a color matrix on a paint (a ColorMatrixPaint), and a CustomPaint record fills rows of premultiplied colors from C#.

// Fill a shape with a photo tiled as a pattern, half size, turned 15 degrees.
using Raster photo = Raster.Load("duck.png");
using Raster<Rgba8> raster = Raster.Create(400, 300, Rgba8.White);
using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas())
{
    ImagePaint pattern = new(photo, TileMode.Mirror, TileMode.Mirror)
    {
        LocalMatrix = Matrix3x2.CreateScale(0.5f) * Matrix3x2.CreateRotation(MathF.PI / 12),
    };
    canvas.FillPath(Path.Star(new Vector2(200, 150), 140, 60, 5), pattern);
}
using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas())
{
    // SVG's feTurbulence fractal noise, stitched so a 128-pixel tile repeats without a seam, made gray by a color matrix.
    Paint stone = NoisePaint.Fractal(0.03f, 0.03f, octaves: 5, seed: 7, tileSize: new Size(128, 128))
        .WithColorMatrix(new ColorMatrix([0.3f, 0.6f, 0.1f, 0, 0, 0.3f, 0.6f, 0.1f, 0, 0, 0.3f, 0.6f, 0.1f, 0, 0, 0, 0, 0, 0, 1]));
    canvas.DrawPaint(stone);

    // Smooth simplex noise as a soft overlay.
    canvas.DrawPaint(NoisePaint.Simplex(0.01f, octaves: 3) with { BlendMode = BlendMode.Overlay, Opacity = 0.5f });
}
LinearGradient sky = new(new Vector2(0, 0), new Vector2(0, 100), new RgbaF32(0.2f, 0.4f, 0.9f, 1), new RgbaF32(0.9f, 0.6f, 0.4f, 1));
NoisePaint clouds = NoisePaint.Fractal(0.02f, 0.05f, 4, seed: 3);

// Noise multiplied onto the gradient, then the whole paint turned to sepia.
Paint paint = new BlendPaint(sky, clouds, BlendMode.SoftLight).WithColorMatrix(ColorMatrix.Sepia(0.6f));
using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas())
{
    canvas.DrawPaint(paint);
}
// Concentric rings from C#: a record class whose Shade fills one row of premultiplied colors. It runs on several threads at
// once, so it keeps no state.
public sealed record class Rings(float Spacing) : CustomPaint
{
    public override void Shade(int y, int x, Span<PremulRgbaF32> row, in Matrix3x2 inverse)
    {
        for (int i = 0; i < row.Length; i++)
        {
            Vector2 p = Vector2.Transform(new Vector2(x + i + 0.5f, y + 0.5f), inverse);
            float v = 0.5f + (0.5f * MathF.Cos(p.Length() * MathF.Tau / Spacing));
            row[i] = new PremulRgbaF32(v, v * 0.6f, 0.2f, 1);
        }
    }
}

A colour can also be given as printing inks: new SolidPaint(InkColor), a GradientStop with an InkColor, and Mesh.WithInks and Patch.WithInks for vertex and corner colours. An ink canvas prints the inks; every other canvas, SVG included, draws the colour's appearance in sRGB. Paint.Overprint (an Overprint) says which plates a paint prints on an ink canvas, as PDF's overprint does; RGB canvases ignore it. The colour management guide covers inks.

// A spot colour, overprinting what is below. An ink canvas prints its plate; this RGB canvas draws its appearance.
var logo = Colors.InkColor.Spot("PANTONE 186 C", 1, Colors.InkAppearance.Cmyk(0, 1, 0.81f, 0.04f));
canvas.FillRect(new RectF(10, 10, 80, 40), new SolidPaint(logo) { Overprint = Overprint.On });

Layers

SaveLayer sends later drawing into a layer that the matching Restore merges with an opacity, a blend mode, a filter (any chain of Egami's operations that keeps the size) and a soft mask: what PDF calls a transparency group. Two overlapping shapes in a half-opacity layer fade as one. The layer covers only its bounds inside the clip, grown by the filter's reach (RasterOps.Reach, an OperationReach), so a blur near the clip's edge still sees what was drawn just outside it. Backdrop runs a chain on what lies behind the layer first, for frosted glass, and Isolated = false starts the layer from the backdrop.

using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas())
{
    // Group opacity: the overlapping circles fade as one shape, with no darker overlap.
    canvas.SaveLayer(0.5f);
    canvas.FillCircle(new Vector2(80, 100), 60, new Rgba8(200, 30, 30, 255));
    canvas.FillCircle(new Vector2(140, 100), 60, new Rgba8(200, 30, 30, 255));
    canvas.Restore();

    // A group blended with Multiply and blurred as a whole; the layer grows by the blur's reach, so nothing is cut.
    canvas.SaveLayer(new LayerOptions { BlendMode = BlendMode.Multiply, Filter = ops => ops.Blur(4) });
    canvas.FillRect(new RectF(180, 40, 90, 120), new Rgba8(40, 120, 220, 255));
    canvas.FillCircle(new Vector2(225, 100), 30, Rgba8.White);
    canvas.Restore();
}

A SoftMask is drawn on its own canvas; its luminosity over a backdrop color, or its alpha, through an optional transfer curve, multiplies the layer, as a PDF soft mask does.

using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas())
{
    // A luminosity mask, as a PDF soft mask: white shows the photo, black hides it, a gradient fades it out to the right.
    RectF area = new(0, 0, photo.Width, photo.Height);
    SoftMask fade = new(mask => mask.FillRect(area, new LinearGradient(new Vector2(area.Width * 0.6f, 0), new Vector2(area.Width, 0), new RgbaF32(1, 1, 1, 1), new RgbaF32(0, 0, 0, 1))));
    canvas.SaveLayer(new LayerOptions { Mask = fade });
    canvas.DrawImage(photo, Vector2.Zero);
    canvas.Restore();
}

Common layer effects

In Egami each layer effect is a LayerOptions.Filter or Backdrop chain, so any Egami operation works on a layer.

Effect Egami
Gaussian or box blur effect Filter = o => o.Blur(sigma) or o.BoxBlur(radius)
Drop shadow Filter = o => o.DropShadow(dx, dy, sigma, color, new DropShadowOptions { Expand = false })
Outer glow Filter = o => o.Glow(sigma, color, spread)
Inner glow Filter = o => o.Glow(sigma, color, spread, inner: true)
Inner shadow Filter = o => o.InnerShadow(dx, dy, sigma, color)
Color matrix, brightness, contrast, saturation, grayscale, sepia, hue Filter = o => o.ColorMatrix(...) and the tone steps
Opacity LayerOptions.Opacity
Blend mode LayerOptions.BlendMode
Backdrop blur and other backdrop effects Backdrop = o => o.Blur(sigma) and any other chain

Blurs and shadows

Paint.Blur takes a MaskBlur: the shape's coverage blurred by a sigma in device pixels before the paint is applied, keeping the inside and outside as its BlurStyle says, as Skia's blur mask filter. Axis-aligned rectangles with the normal style are blurred exactly, from the error function. DrawShadow casts a Material-style shadow from a shape's elevation and a light, after Skia's shadow utilities; a group's drop shadow is a layer filter.

using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas())
{
    // A soft-edged card (exact, from the error function) and a neon outline: the stroke's outer blur only, then the stroke.
    canvas.FillRoundRect(new RoundRect(new RectF(20, 20, 110, 80), 12), new SolidPaint(new Rgba8(30, 30, 30, 255)) { Blur = new MaskBlur(6) });
    Path ring = Path.Circle(new Vector2(220, 60), 40);
    canvas.StrokePath(ring, new Stroke(4), new SolidPaint(new Rgba8(255, 40, 200, 255)) { Blur = new MaskBlur(8, BlurStyle.Outer) });
    canvas.StrokePath(ring, new Stroke(4), new Rgba8(255, 200, 250, 255));
}
using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas())
{
    // A card floating 12 pixels up: its ambient and spot shadows, then the card itself.
    RoundRect card = new(new RectF(60, 40, 180, 110), 10);
    canvas.DrawShadow(Path.RoundRect(card), new ShadowOptions { Elevation = 12 });
    canvas.FillRoundRect(card, Rgba8.White);

    // A whole group's drop shadow: a layer with a drop shadow filter that keeps the layer's size.
    canvas.SaveLayer(new LayerOptions { Filter = ops => ops.DropShadow(3, 4, 3, new Rgba8(0, 0, 0, 90), new DropShadowOptions { Expand = false }) });
    canvas.FillCircle(new Vector2(110, 95), 20, new Rgba8(230, 80, 40, 255));
    canvas.FillRect(new RectF(140, 80, 60, 30), new Rgba8(40, 120, 220, 255));
    canvas.Restore();
}

Clips

ClipRect, ClipRoundRect and ClipPath take a ClipOp: Intersect keeps the inside, Difference the outside. Clips are antialiased unless asked not to be. A clip that is a pixel-aligned rectangle costs nothing; any other clip is a coverage mask of one byte per pixel of its bounds, and drawing through it costs about 1.2 times drawing without.

// Clips are antialiased and last until the matching Restore.
canvas.Save();
canvas.ClipPath(Path.Circle(new Vector2(100, 100), 80));
canvas.ClipRect(new RectF(60, 60, 80, 80), ClipOp.Difference);   // a square hole
canvas.DrawPaint(new Rgba8(30, 90, 200, 255));                    // fills the whole clip
canvas.Restore();

// Only whole pixels inside the clip are drawn after a pixel-aligned rectangle clip, at no extra cost.
canvas.ClipRect(new RectF(0, 0, 100, 200));
canvas.FillCircle(new Vector2(100, 190), 40, Rgba8.Black);

Images

DrawImage draws all or part of an image into a rectangle, under the transform and clip, sampled with Egami's SamplingOptions. At its own size on whole pixels it blends exactly as DrawImage in an operation chain does.

using Raster photo = Raster.Load("duck.png");
using Raster<Rgba8> page = Raster.Create(800, 600, Rgba8.White);
using (RasterCanvas<Rgba8> canvas = page.CreateCanvas())
{
    // At its own size: whole-pixel placement blends exactly as DrawImage does.
    canvas.DrawImage(photo, new Vector2(10, 10));

    // Scaled into a rectangle and turned, sampled with any of Egami's filters.
    canvas.Save();
    canvas.Rotate(-8, new Vector2(500, 300));
    canvas.DrawImage(photo, new RectF(350, 150, 300, 300 * photo.Height / photo.Width), new SamplingOptions { Filter = ResampleFilter.Lanczos3 });
    canvas.Restore();

    // A part of the image, at half opacity.
    canvas.DrawImage(photo, source: new RectF(0, 0, 100, 100), destination: new RectF(10, 450, 140, 140), paint: new SolidPaint(Rgba8.Black) { Opacity = 0.5f });
}

Nine-patches and lattices

DrawImageNinePatch stretches an image to a rectangle while its corners keep their size: the edges stretch one way and the center both ways, as Skia's drawImageNine. DrawImageLattice generalizes it: a Lattice's x and y divisions cut the image into columns and rows that alternate fixed and stretchable, and each cell can draw its part of the image, nothing, or a fixed color (LatticeCellKind). Fixed parts shrink in proportion when the destination is too small for them. NinePatch.FromAndroid reads an Android .9.png: the one-pixel border's black markers become the lattice, and the right and bottom markers the content padding.

using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas())
{
    // An Android .9.png: its one-pixel border marks what stretches. The corners stay sharp at any size.
    (Raster inner, Lattice lattice, Rect? padding) = NinePatch.FromAndroid(file);
    canvas.DrawImageLattice(inner, lattice, new RectF(10, 10, 180, 48));
    inner.Dispose();

    // A nine-patch from a plain image: the 8-pixel corners keep their size, the edges and center stretch.
    canvas.DrawImageNinePatch(frame, new Rect(8, 8, 8, 8), new RectF(10, 70, 120, 40));

    // A lattice can leave cells out or fill them with a color: here the center becomes a flat panel.
    LatticeCellKind[] kinds = [.. Enumerable.Repeat(LatticeCellKind.Default, 9)];
    kinds[4] = LatticeCellKind.FixedColor;
    RgbaF32[] colors = new RgbaF32[9];
    colors[4] = new RgbaF32(0.95f, 0.95f, 0.9f, 1);
    canvas.DrawImageLattice(frame, new Lattice([8, 16], [8, 16]) { CellKinds = kinds, CellColors = colors }, new RectF(150, 70, 140, 40));
}

The raster backend draws the cells as one textured mesh, so neighboring cells meet without seams at any scale or position.

Sprite atlases

DrawAtlas draws many sprites from one image in one call, as Skia's drawAtlas: each sprite is a source rectangle placed by a SpriteTransform (a rotation and uniform scale, then a move, as Skia's RSXform), and may be tinted by a color combined with it by a blend mode. Each sprite blends with what lies below on its own, so additive particles add up. It pairs with SpriteSheet.Pack; the sheet's texels are prepared once for the whole call.

// Pack the sprites once, then draw thousands of them in one call: each with its own place, turn, scale and tint.
using SpriteSheet sheet = SpriteSheet.Pack([spark, star], new SpritePackOptions { Padding = 1 });
Random random = new(7);
SpriteTransform[] transforms = new SpriteTransform[2000];
RectF[] sources = new RectF[transforms.Length];
RgbaF32[] tints = new RgbaF32[transforms.Length];
for (int i = 0; i < transforms.Length; i++)
{
    Rect frame = sheet.Frames[i % 2];
    sources[i] = frame;
    transforms[i] = SpriteTransform.FromRotationScale(
        scale: 0.3f + random.NextSingle(),
        degrees: random.NextSingle() * 360,
        position: new Vector2(random.NextSingle() * 320, random.NextSingle() * 200),
        anchor: new Vector2(frame.Width / 2f, frame.Height / 2f));
    tints[i] = new RgbaF32(1, 0.5f + (random.NextSingle() / 2), random.NextSingle(), 0.8f);
}

using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas())
{
    // Plus adds the sprites up, as light does.
    canvas.DrawAtlas(sheet.Image, transforms, sources, tints, BlendMode.Modulate, paint: new SolidPaint(Rgba8.White) { BlendMode = BlendMode.Plus });
}

Meshes and patches

A Mesh is triangles (MeshMode: a list, a strip or a fan, from the vertices or from indices) with an optional color, texture coordinate or value at each vertex. DrawMesh fills the triangles' union once, antialiased on its outer edges only, so a mesh has no cracks between its triangles, which PDF requires of its mesh shadings; where triangles overlap, the later one shows. Vertex colors interpolate in the canvas's color space, premultiplied (Gouraud shading); with texture coordinates, the paint is mapped onto each triangle; and the two combine by a blend mode, the vertex color as the destination. As in Skia, a solid paint's color gives way to the vertex colors and only its alpha applies.

using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas())
{
    // Gouraud shading: a color at each corner, blended across each triangle without cracks between them.
    Vector2[] quad = [new(10, 10), new(140, 20), new(130, 140), new(20, 120)];
    RgbaF32[] colors = [new(1, 0.2f, 0.2f, 1), new(1, 0.9f, 0.2f, 1), new(0.2f, 0.6f, 1, 1), new(0.6f, 0.2f, 0.9f, 1)];
    canvas.DrawMesh(new Mesh(MeshMode.Fan, quad, colors), Rgba8.White);

    // A textured mesh: each vertex says which point of the paint lands on it, here bending a photo into a trapezoid.
    Vector2[] trapezoid = [new(170, 20), new(280, 30), new(290, 140), new(160, 130)];
    Vector2[] corners = [new(0, 0), new(64, 0), new(64, 64), new(0, 64)];
    canvas.DrawMesh(new Mesh(MeshMode.Fan, trapezoid, texCoords: corners), new ImagePaint(photo));
}

A Patch is a curved four-sided surface: a Coons patch from four cubic edges (Patch.Coons, 12 points, PDF shading type 6 and Skia's drawPatch) or a tensor-product patch from a 4×4 net (Patch.Tensor, PDF type 7), with colors, values or texture coordinates at its corners. DrawPatch cuts it into triangles under the current transform until they stray less than a quarter of a device pixel from the surface, and where it folds over itself the larger parameters cover the smaller, as PDF specifies.

// A Coons patch: four cubic edges, clockwise from the top-left corner, and a color at each corner.
Vector2[] edges =
[
    new(20, 20), new(80, 0), new(150, 40), new(220, 20),    // top, left to right
    new(200, 80), new(240, 140), new(220, 180),             // right, down
    new(150, 160), new(80, 200), new(20, 180),              // bottom, right to left
    new(40, 120), new(0, 60),                               // left, up
];
Patch patch = Patch.Coons(edges, [new RgbaF32(1, 0, 0, 1), new RgbaF32(1, 1, 0, 1), new RgbaF32(0, 0, 1, 1), new RgbaF32(0, 1, 0, 1)]);
using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas())
{
    canvas.DrawPatch(patch);

    // PDF's function-based shadings give each corner a value instead, mapped through a gradient's stops.
    Patch shaded = Patch.Tensor(patch.ToTensorNet().Span, cornerValues: [0, 0.3f, 1, 0.6f]);
    canvas.Translate(10, 10);
    canvas.Scale(0.3f);
    canvas.DrawPatch(shaded, valueStops: [new GradientStop(0, Rgba8.Black), new GradientStop(1, Rgba8.White)]);
}

Values are PDF's way of shading through a function: each vertex or corner carries one number t, interpolated, then mapped to a color through gradient stops, so a renderer of PDF shadings types 4 to 7 hands Egami its meshes and patches directly and the colors stay smooth inside each triangle.

Recorded drawings

A PictureRecorder is a canvas that records instead of drawing; Finish returns an immutable Picture. DrawPicture replays it on any canvas under a matrix, through the public calls, so it stays sharp at any scale and a vector backend receives it as vectors. A paint with an opacity or a blend mode merges the whole drawing as one layer; cacheAsImage draws an image of it rendered at the current scale and kept with the drawing, for a drawing repeated many times at one scale. ToRaster renders it, and Playback replays it without the state DrawPicture adds. A drawing replays on many threads and canvases at once.

// Record any picture code once...
PictureRecorder recorder = new(new RectF(0, 0, 40, 40));
DrawLogo(recorder);
Picture logo = recorder.Finish();

using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas())
{
    // ...and replay it anywhere, at any scale, still sharp: replay is vector.
    canvas.DrawPicture(logo, Matrix3x2.CreateTranslation(10, 10));
    canvas.DrawPicture(logo, Matrix3x2.CreateScale(4) * Matrix3x2.CreateTranslation(60, 10));

    // A paint with an opacity merges the whole picture as one layer.
    canvas.DrawPicture(logo, Matrix3x2.CreateTranslation(240, 10), new SolidPaint(Rgba8.White) { Opacity = 0.4f });

    // Drawn many times at one scale, a cached image is cheaper than replaying.
    for (int i = 0; i < 6; i++)
    {
        canvas.DrawPicture(logo, Matrix3x2.CreateTranslation(230 + (i * 25), 150), cacheAsImage: true);
    }
}

using Raster<Rgba8> icon = logo.ToRaster<Rgba8>(scale: 2);

The drawing is recorded once and replayed as vectors, so it is sharp at every size; cacheAsImage trades that for speed when it is drawn many times at one size.

Drawing patterns

A PicturePaint fills shapes with a drawing repeated by tile modes: the tiling patterns of PDF and SVG, and Skia's picture shader. The raster backend renders one tile at the scale it is drawn at and keeps it with the drawing, so a zoomed pattern stays sharp.

// A tile drawn once as vectors fills any shape, repeated, and stays sharp when the canvas is zoomed.
PictureRecorder tile = new(new RectF(0, 0, 20, 20));
tile.FillCircle(new Vector2(10, 10), 4, new Rgba8(220, 60, 60, 255));
tile.StrokeRect(new RectF(0.5f, 0.5f, 19, 19), new Stroke(1), new Rgba8(230, 230, 230, 255));
PicturePaint dots = new(tile.Finish(), TileMode.Repeat, TileMode.Repeat) { LocalMatrix = Matrix3x2.CreateRotation(MathF.PI / 8) };
using (RasterCanvas<Rgba8> canvas = raster.CreateCanvas())
{
    canvas.FillRoundRect(new RoundRect(new RectF(10, 10, 280, 180), 24), dots);
}

Documents

A canvas also carries what a document backend needs and pixels never show. DrawImage takes an EncodedImage, a file's bytes probed but not decoded, so a PDF page can embed a JPEG as it is while a raster canvas draws the decoded pixels. DrawImageMask paints through an image used as a mask, as PDF's stencil masks: its alpha, or for an image without alpha its gray level, is the coverage. LayerOptions.Knockout makes a layer a knockout group: each call inside it composites with what the layer started as and replaces earlier calls where it covers. A layer inside it counts as one call, which replaces what is under it wherever it drew. DrawAnnotation marks a link to a URL, a named destination or a link to one (Annotation, as Skia's drawAnnotation), and BeginMarkedContent and EndMarkedContent bracket drawing with a MarkedContent tag for tagged PDF, with a structure node id, replacement text and alternate text. Backends get OnDrawEncodedImage, OnDrawAnnotation, OnBeginMarkedContent and OnEndMarkedContent; by default the image is decoded and the others do nothing.

// A JPEG kept as its bytes: a PDF backend embeds them as they are; a raster canvas draws the decoded pixels.
EncodedImage jpeg = EncodedImage.Load("photo.jpg");
canvas.DrawImage(jpeg, new RectF(20, 20, 128, 96));
// A 1-bit scan as a stencil: its white pixels paint in ink, its black ones leave the page as it is.
using Raster<Gray8> scan = Raster.Create(32, 16, new Gray8(255));
canvas.DrawImageMask(scan, new RectF(160, 20, 64, 32), new Rgba8(20, 40, 120, 255), new SamplingOptions { Filter = ResampleFilter.Nearest });

// A knockout group: each shape composites with what the group started as, so overlapping translucent shapes do not darken.
canvas.SaveLayer(new LayerOptions { Knockout = true });
canvas.FillCircle(new Vector2(180, 100), 20, new RgbaF32(1, 0, 0, 0.5f));
canvas.FillCircle(new Vector2(200, 100), 20, new RgbaF32(0, 0, 1, 0.5f));
canvas.Restore();
// A layout engine marks links, destinations and structure while it draws; pixels show none of it.
canvas.BeginMarkedContent(new MarkedContent("P") { NodeId = 12 });
canvas.DrawAnnotation(new RectF(20, 130, 120, 16), Annotation.Url("https://example.com"));
canvas.DrawAnnotation(new RectF(20, 150, 0, 0), Annotation.NamedDestination("chapter-2"));
canvas.EndMarkedContent();

Saving drawings

Picture.Save writes Egami's own documented binary format, .egdr, and Picture.Load reads it back as untrusted input, under the context's safety limits. Images are kept as PNG, text as its outlines. A drawing that holds C# code (a CustomPaint, or a layer's filter or soft mask) cannot be saved: Save throws and names the call.

// Keep a picture in Egami's own documented format, and load it later; a loaded file is untrusted, so the context's
// safety limits apply.
using (FileStream file = File.Create("logo.egdr"))
{
    logo.Save(file);
}

Picture loaded;
using (FileStream file = File.OpenRead("logo.egdr"))
{
    loaded = Picture.Load(file);
}

Path geometry

Paths can be combined, outlined, dashed, measured, reshaped by effects, and read from and written as SVG path data. Every result is a new Path that keeps lines, quadratics and cubics where the input had them, so a vector backend writes it exactly.

Boolean operations

Op combines two areas with a PathOp: Union, Intersect, Difference, Xor or ReverseDifference (with shortcuts for the first four). Path.Combine takes any number of paths in one pass, applied left to right. Simplify returns the same area without overlaps or self-intersections.

// A frame: one round rectangle cut out of another, kept as curves, so a PDF or SVG backend writes it exactly.
Path frame = Path.RoundRect(new RoundRect(new RectF(0, 0, 200, 140), 24))
    .Difference(Path.RoundRect(new RoundRect(new RectF(20, 20, 160, 100), 12)));

// Many shapes in one pass, applied left to right; overlaps and self-intersections removed; an area grown by 4 units.
Path dots = Path.Combine([Path.Circle(new Vector2(40, 40), 30), Path.Circle(new Vector2(80, 40), 30), Path.Circle(new Vector2(60, 75), 30)], PathOp.Union);
Path clean = Path.Star(new Vector2(50, 50), 45, 20, 5).Simplify();
Path halo = dots.Inflate(4, LineJoin.Round);

Boolean operations keep curves, so the frame exports to PDF or SVG exactly. See Path geometry.

  • Each input keeps its fill rule; open contours count as closed. Results use NonZero, with outer contours clockwise on screen and holes counterclockwise, and no contours cross.
  • Crossings are found on the curves themselves and cut exactly. The tolerance (0.05 units by default) is how close points must be to count as the same: ends and crossings within a tenth of it merge, and edges that run within it of each other merge, so nearly coincident outlines leave no slivers. Pass a smaller tolerance for geometry that will be drawn larger.
  • Inflate grows an area by a distance, or shrinks it for a negative one, with a LineJoin at the corners.

Outlines and dashes

Stroke.ToPath returns the outline a pen draws, as a path to fill: filled at the given scale it gives exactly the stroke's pixels, and simplify: true removes its overlaps for export or hit testing. Stroke.Dash takes a DashPattern of absolute on and off lengths and a phase, as PDF, SVG and Skia define them; Scaled turns lengths given in widths into units.

// A dashed route: the pattern travels with the stroke, so a PDF backend writes [8 4] 0 d instead of hundreds of pieces.
Path route = new PathBuilder().MoveTo(10, 90).CubicTo(60, 0, 140, 160, 190, 60).ToPath();
Stroke dashed = new(3) { Cap = LineCap.Round, Dash = new DashPattern([8, 4]) };
using Raster<Rgba8> map = Raster.Create(200, 120, Rgba8.White);
using (RasterCanvas<Rgba8> canvas = map.CreateCanvas())
{
    canvas.StrokePath(route, dashed, new Rgba8(200, 60, 30, 255));
}

// The outline the stroke fills, as a path: for hit testing, or to export without strokes.
Path outline = dashed.ToPath(route, simplify: true);

A backend receives the pattern as data, so PDF can write d and SVG stroke-dasharray; the raster backend cuts the path into dashes along its curves before outlining it.

Measuring

PathMeasure lists each contour as a ContourMeasure: its Length, and the position, tangent and placing matrix at any distance. GetSegment returns a piece of the contour that keeps its curves; AddSegment chains pieces into one contour. Lengths are integrated, not taken from a flattened outline, and are exact to about 1e-4 units.

// Measure a path: its length, and a point and direction at any distance, curves included.
Path route = new PathBuilder().MoveTo(10, 90).CubicTo(60, 0, 140, 160, 190, 60).ToPath();
PathMeasure measure = new(route);
ContourMeasure contour = measure.Contours[0];
Vector2 halfway = contour.GetPosition(contour.Length / 2);
Vector2 heading = contour.GetTangent(contour.Length / 2);

// An arrowhead placed at the end, turned along the path, and the middle third as a path of its own (still a cubic).
Path arrow = Path.Polygon([new(-6, -4), new(0, 0), new(-6, 4)]).Transform(contour.GetMatrix(contour.Length));
Path middle = contour.GetSegment(contour.Length / 3, 2 * contour.Length / 3);

Path effects

A PathEffect turns one path into another: Dash, Corner (rounded corners), Trim, Jitter, Stamp (a shape repeated along the path, placed by a StampStyle), Hatch and HatchPath (lines or shapes on a lattice inside an area). Then and Plus combine them. path.Apply(effect) applies one; Stroke.Effect has the canvas apply it before dashing and stroking.

// A hand-drawn chart line: jittered the same way on every run, then its corners rounded.
Path data = Path.Polygon([new(10, 80), new(50, 30), new(90, 60), new(130, 20), new(170, 50)], close: false);
PathEffect sketchy = PathEffect.Jitter(segmentLength: 6, deviation: 1.5f, seed: 7).Then(PathEffect.Corner(3));
using Raster<Rgba8> chart = Raster.Create(180, 100, Rgba8.White);
using (RasterCanvas<Rgba8> canvas = chart.CreateCanvas())
{
    canvas.StrokePath(data, new Stroke(2) { Effect = sketchy }, new Rgba8(40, 40, 40, 255));

    // Dots along the line, and hatching inside a circle.
    canvas.FillPath(data.Apply(PathEffect.Stamp(Path.Circle(Vector2.Zero, 2), advance: 12, style: StampStyle.Translate)), Rgba8.Black);
    canvas.FillPath(Path.Circle(new Vector2(150, 75), 20).Apply(PathEffect.Hatch(Matrix3x2.CreateScale(1, 5) * Matrix3x2.CreateRotation(0.6f), width: 1)), Rgba8.Black);
}

// A line that draws itself: trim it to a growing fraction of its length, frame by frame.
Path frame30 = data.Apply(PathEffect.Trim(0, 0.3f));

Jitter hashes its seed with the contour and the point, so a path jitters the same way on every thread and machine.

SVG path data

Path.ParseSvg reads the d attribute grammar of SVG 1.1 and 2; TryParseSvg returns false instead of throwing SvgPathException, and lenient: true keeps every complete command before the first error. ToSvg writes path data, absolute or relative, compact or spaced, to a precision (SvgPathFormat).

// An icon from SVG path data, drawn four times larger, and written back as compact relative data.
Path heart = Path.ParseSvg("M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09" +
                           "C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z");
string d = heart.Transform(Matrix3x2.CreateScale(4)).ToSvg(new SvgPathFormat { Relative = true, Compact = true });

// Untrusted data: TryParseSvg says whether it is valid; lenient keeps what comes before an error, as browsers draw it.
bool valid = Path.TryParseSvg("M0 0 L10 10 L20", out Path? strict);
Path drawn = Path.ParseSvg("M0 0 L10 10 L20", lenient: true);
try
{
    Path.ParseSvg("M0 0 X");
}
catch (SvgPathException e)
{
    Console.WriteLine($"{e.Problem} at character {e.Position}");
}

In an operation chain

Draw adds a step that draws on each frame with a canvas over the step's pixels (DrawingExtensions).

// Draw as a step in an operation chain: inside Within the canvas covers the area, with its origin at the area's corner.
raster.Apply(ops => ops
    .Draw(canvas => canvas.FillRect(new RectF(0, 0, 300, 200), new Rgba8(240, 240, 255, 255)))
    .Within(new Rect(100, 50, 100, 100), area => area.Draw(canvas => canvas.FillCircle(new Vector2(50, 50), 40, Rgba8.Red))),
    Ct);

// On an animation the step runs once per frame, and can be told which.
raster.Apply(ops => ops.Draw((canvas, frame) => canvas.FillCircle(new Vector2(20 + (frame * 4), 20), 10, Rgba8.Black)), Ct);

Printing inks

InkCanvas draws into an InkRaster's plates instead of RGB pixels. It shares the raster canvas's rasterizer, clips and layers, so shapes, strokes and text cover the same pixels; where the RGB canvas blends colours, it sets each plate. Ink paints print their inks by name, and RGB colours, gradients and images convert into the process inks as InkCanvasOptions says (the RGB encoding, intent and black point compensation). InkCanvas.Clear(InkColor.None) leaves blank paper.

// Plates for CMYK and a spot; an RGB photo converts through FOGRA39, the logo prints on its own plate.
Egami.Colors.InkSet inks = Egami.Colors.InkSet.FromProfile(Egami.Colors.ColorProfile.Fogra39)
    .WithSpot("PANTONE 186 C", Egami.Colors.InkAppearance.Cmyk(0, 1, 0.81f, 0.04f));
using InkRaster page = new(inks, 300, 200);
using (InkCanvas canvas = new(page))
{
    canvas.Clear(Egami.Colors.InkColor.None);                                   // paper
    canvas.FillRect(new RectF(0, 0, 300, 120), new Rgba8(80, 140, 200, 255));   // RGB: converted to CMYK
    canvas.FillCircle(new Vector2(150, 100), 40, new SolidPaint(Egami.Colors.InkColor.Spot(inks[4], 1)));
}

Other backends

Canvas is abstract. Each public call is checked, tracked (transform, clip bounds, save stack) and then ends in one protected hook, so a backend sees shapes and pens as data: a PDF page can write a rectangle as re and a pen as w, J, j and M. Hooks a backend leaves alone have defaults: rectangles, round rectangles and ovals become paths. A backend that cannot express a call (a blend mode its format lacks, a CustomPaint, a filtered layer) can draw it into pixels with RasterizeFallback, which returns a FallbackImage. Layers reach a backend through OnSaveLayer and OnRestoreLayer; a PDF page would write a transparency group with its opacity, blend mode and soft mask. OnDrawMesh and OnDrawPatch let a PDF page write shading types 4 to 7, OnDrawPicture a form XObject drawn once and referenced each time, and OnDrawImageLattice and OnDrawAtlas images per cell or sprite; by default meshes go through the raster fallback, patches become meshes, and the others become images and meshes.

// A backend overrides hooks; the base class has already checked the arguments and tracks the transform and clip bounds.
// This one writes SVG elements, keeping rectangles as rectangles and strokes as stroke settings.
public sealed class SvgCanvas(float width, float height) : Canvas(width, height)
{
    private readonly System.Text.StringBuilder _body = new();

    public string Document => $"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{Width}\" height=\"{Height}\">{_body}</svg>";

    protected override void OnDrawRect(RectF rect, Stroke? stroke, Paint paint)
        => _body.Append(FormattableString.Invariant($"<rect x=\"{rect.X}\" y=\"{rect.Y}\" width=\"{rect.Width}\" height=\"{rect.Height}\" {Style(stroke, paint)}/>"));

    protected override void OnDrawPath(Path path, Stroke? stroke, Paint paint)
    {
        System.Text.StringBuilder d = new();
        int p = 0;
        foreach (PathVerb verb in path.Verbs)
        {
            int count = verb switch { PathVerb.Quad => 2, PathVerb.Cubic => 3, PathVerb.Close => 0, _ => 1 };
            d.Append(verb switch { PathVerb.Move => "M", PathVerb.Line => "L", PathVerb.Quad => "Q", PathVerb.Cubic => "C", _ => "Z" });
            foreach (Vector2 point in path.Points.Slice(p, count))
            {
                d.Append(FormattableString.Invariant($"{point.X},{point.Y} "));
            }

            p += count;
        }

        _body.Append(System.Globalization.CultureInfo.InvariantCulture, $"<path d=\"{d}\" {Style(stroke, paint)}/>");
    }

    protected override void OnClipPath(Path path, ClipOp op, bool antialias)
    {
        // Clips would become <clipPath> elements; this sketch leaves them out.
    }

    protected override void OnDrawImage(Raster image, RectF source, RectF destination, SamplingOptions sampling, Paint? paint)
    {
        // An image would be embedded as a PNG data URI.
    }

    private string Style(Stroke? stroke, Paint paint)
    {
        Rgba8 c = paint is SolidPaint solid ? solid.Color.ToRgba8() : Rgba8.Black;
        string color = $"#{c.R:x2}{c.G:x2}{c.B:x2}";
        string transform = Matrix.IsIdentity ? "" : FormattableString.Invariant($" transform=\"matrix({Matrix.M11} {Matrix.M12} {Matrix.M21} {Matrix.M22} {Matrix.M31} {Matrix.M32})\"");
        return stroke is null
            ? $"fill=\"{color}\"{transform}"
            : FormattableString.Invariant($"fill=\"none\" stroke=\"{color}\" stroke-width=\"{stroke.Width}\" stroke-linecap=\"{stroke.Cap.ToString().ToLowerInvariant()}\"{transform}");
    }
}

Accuracy and speed

Fills are within a mean level of SkiaSharp's and closer than SkiaSharp's to the exact covered area. Boolean operations, outlines, dashes, effects and SVG parsing give paths within a mean level of SkiaSharp's, and measured lengths agree within 1e-3. The figures are in the 2.6.1 and 2.6.2 release notes.

Recipes

Short answers to common tasks in this area. Each one compiles and runs in Egami's test run.

Draw a rounded badge

// A rounded badge with an outline and a dot, drawn at twice the size for a sharp result on high-density screens.
using Raster<Rgba8> badge = Raster.Create<Rgba8>(240, 80);
using (RasterCanvas<Rgba8> canvas = badge.CreateCanvas())
{
    canvas.Scale(2);
    RoundRect pill = new(new RectF(2, 2, 116, 36), 18);
    canvas.FillRoundRect(pill, new Rgba8(46, 160, 67, 255));
    canvas.StrokeRoundRect(pill, new Stroke(2), new Rgba8(20, 100, 40, 255));
    canvas.FillCircle(new Vector2(22, 20), 7, Rgba8.White);
}

Drawing at twice the size through Scale(2) keeps every shape's coordinates as designed.

Draw chart axes

// Axes, ticks and a data line for a chart: hairlines stay one pixel wide whatever the scale.
double[] values = [3, 7, 4, 9, 6, 11, 8];
using Raster<Rgba8> chart = Raster.Create(420, 260, Rgba8.White);
using (RasterCanvas<Rgba8> canvas = chart.CreateCanvas())
{
    Rgba8 axis = new(60, 60, 60, 255);
    canvas.DrawLine(new Vector2(40.5f, 20), new Vector2(40.5f, 230.5f), Stroke.Hairline, axis);
    canvas.DrawLine(new Vector2(40, 230.5f), new Vector2(400, 230.5f), Stroke.Hairline, axis);
    for (int i = 0; i < values.Length; i++)
    {
        float x = 40.5f + (i * 55);
        canvas.DrawLine(new Vector2(x, 230), new Vector2(x, 236), Stroke.Hairline, axis);
    }

    Vector2[] points = [.. values.Select((v, i) => new Vector2(40 + (i * 55), 230 - (float)(v * 18)))];
    canvas.DrawPoints(PointMode.Polygon, points, new Stroke(3) { Join = LineJoin.Round, Cap = LineCap.Round }, new Rgba8(30, 90, 200, 255));
    canvas.DrawPoints(PointMode.Points, points, new Stroke(8) { Cap = LineCap.Round }, new Rgba8(30, 90, 200, 255));
}

Hairlines on half-pixel coordinates fill exactly one column or row of pixels.

Clip a photo to a circle

// A round avatar: clip to a circle, draw the photo scaled to fill it, then ring it.
using Raster photo = Raster.Load("duck.png");
using Raster<Rgba8> avatar = Raster.Create<Rgba8>(256, 256);
using (RasterCanvas<Rgba8> canvas = avatar.CreateCanvas())
{
    Path circle = Path.Circle(new Vector2(128, 128), 120);
    canvas.Save();
    canvas.ClipPath(circle);
    int side = Math.Min(photo.Width, photo.Height);
    RectF square = new((photo.Width - side) / 2, (photo.Height - side) / 2, side, side);
    canvas.DrawImage(photo, square, new RectF(8, 8, 240, 240));
    canvas.Restore();
    canvas.StrokePath(circle, new Stroke(6), Rgba8.White);
}

avatar.Save("avatar.png");

Animate a line drawing itself

// Frames of a signature drawing itself: each frame strokes a longer part of the path.
Path signature = Path.ParseSvg("M10 60 C30 10 50 110 70 60 S110 10 130 60 S170 110 190 40");
List<Raster<Rgba8>> frames = [];
for (int i = 1; i <= 24; i++)
{
    Raster<Rgba8> frame = Raster.Create(200, 100, Rgba8.White);
    using (RasterCanvas<Rgba8> canvas = frame.CreateCanvas())
    {
        Stroke pen = new(3) { Cap = LineCap.Round, Effect = PathEffect.Trim(0, i / 24f) };
        canvas.StrokePath(signature, pen, new Rgba8(20, 40, 120, 255));
    }

    frames.Add(frame);
}

PathEffect.Trim keeps a fraction of the path's length; stepping it from 0 to 1 gives the frames.

Paint a dusk sky

// A dusk sky: stops in Oklch so the hues turn smoothly, dithered so the wide soft ramp shows no bands, and a sun.
using Raster<Rgba8> sky = Raster.Create<Rgba8>(800, 450);
using (RasterCanvas<Rgba8> canvas = sky.CreateCanvas())
{
    canvas.DrawPaint(new LinearGradient(
        new Vector2(0, 0),
        new Vector2(0, 450),
        new GradientStop(0, new Rgba8(20, 30, 90, 255)),
        new GradientStop(0.6f, new Rgba8(220, 110, 120, 255)),
        new GradientStop(1, new Rgba8(255, 200, 120, 255)))
    {
        Interpolation = GradientInterpolation.Oklch(),
        Dither = true,
    });
    canvas.FillCircle(new Vector2(560, 380), 160, new RadialGradient(new Vector2(560, 380), 160, new RgbaF32(1, 0.95f, 0.8f, 0.9f), new RgbaF32(1, 0.8f, 0.5f, 0)));
}

sky.Save("sky.png");

Mixing in Oklch keeps the hues from turning gray between stops, and Dither hides the bands of a wide soft ramp. See Gradients.

Draw a card with a shadow

// A UI card: a Material-style shadow from its elevation, the card, and a soft inner highlight along its top edge.
using Raster<Rgba8> screen = Raster.Create(400, 260, new Rgba8(236, 239, 244, 255));
using (RasterCanvas<Rgba8> canvas = screen.CreateCanvas())
{
    RoundRect card = new(new RectF(50, 40, 300, 170), 14);
    canvas.DrawShadow(Path.RoundRect(card), new ShadowOptions { Elevation = 16 });
    canvas.FillRoundRect(card, Rgba8.White);
    canvas.Save();
    canvas.ClipRoundRect(card);
    canvas.FillRect(new RectF(50, 40, 300, 30), new LinearGradient(new Vector2(0, 40), new Vector2(0, 70), new RgbaF32(0.85f, 0.9f, 1, 1), new RgbaF32(1, 1, 1, 1)));
    canvas.Restore();
}

screen.Save("card.png");

Fade a photo's edges

// A photo whose edges fade to transparent: an alpha soft mask of a radial gradient, opaque in the middle.
using Raster photo = Raster.Load("duck.png");
using Raster<Rgba8> faded = Raster.Create<Rgba8>(photo.Width, photo.Height);
using (RasterCanvas<Rgba8> canvas = faded.CreateCanvas())
{
    Vector2 center = new(photo.Width / 2f, photo.Height / 2f);
    float radius = MathF.Min(photo.Width, photo.Height) / 2f;
    SoftMask vignette = new(
        mask => mask.DrawPaint(new RadialGradient(center, radius, new GradientStop(0.6f, Rgba8.Black), new GradientStop(1, new Rgba8(0, 0, 0, 0)))),
        SoftMaskKind.Alpha);
    canvas.SaveLayer(new LayerOptions { Mask = vignette });
    canvas.DrawImage(photo, Vector2.Zero);
    canvas.Restore();
}

faded.Save("faded.png");

Fade a group as one

// A translucent logo made of overlapping parts: drawn in a layer at 60%, the overlaps do not show through.
using Raster<Rgba8> banner = Raster.Create(300, 120, new Rgba8(30, 40, 60, 255));
using (RasterCanvas<Rgba8> canvas = banner.CreateCanvas())
{
    canvas.SaveLayer(0.6f);
    canvas.FillCircle(new Vector2(60, 60), 40, new Rgba8(255, 200, 0, 255));
    canvas.FillRoundRect(new RoundRect(new RectF(60, 40, 200, 40), 20), new Rgba8(255, 200, 0, 255));
    canvas.Restore();
}

banner.Save("banner.png");

See Layers.

Stretch a button image to any size

// One small button image serves every size: corners stay round, the label sits on the stretched center.
using Raster<Rgba8> button = Raster.Create<Rgba8>(200, 48);
using (RasterCanvas<Rgba8> canvas = button.CreateCanvas())
{
    canvas.DrawImageNinePatch(skin, new Rect(12, 12, 6, 6), new RectF(0, 0, 200, 48), new SamplingOptions { Filter = ResampleFilter.Bilinear });
}

The corners keep their size and the edges and center stretch. An Android .9.png carries its own lattice: see Nine-patches and lattices.

Shade a gradient mesh

// A gradient mesh, as an illustration app draws one: a 3×3 grid of colored points, smoothly shaded.
Vector2[] grid = new Vector2[16];
RgbaF32[] colors = new RgbaF32[16];
for (int j = 0; j < 4; j++)
{
    for (int i = 0; i < 4; i++)
    {
        grid[(j * 4) + i] = new Vector2(i * 60, j * 40) + new Vector2(MathF.Sin(j) * 8, MathF.Cos(i) * 6);
        colors[(j * 4) + i] = new RgbaF32(i / 3f, 0.3f + (j * 0.2f), 1 - (i / 3f), 1);
    }
}

int[] indices = [.. Enumerable.Range(0, 9).SelectMany(k => new[] { k + (k / 3), k + (k / 3) + 1, k + (k / 3) + 5, k + (k / 3), k + (k / 3) + 5, k + (k / 3) + 4 })];
using Raster<Rgba8> mesh = Raster.Create(200, 140, Rgba8.White);
using (RasterCanvas<Rgba8> canvas = mesh.CreateCanvas())
{
    canvas.Translate(10, 10);
    canvas.DrawMesh(new Mesh(MeshMode.Triangles, grid, colors, indices: indices), Rgba8.White);
}

See Meshes and patches.

Separations of a CMYK page with spot colours

// A CMYK page with two spot colours: a photo converted into the process inks, a Pantone band and a gold rule.
InkSet inks = InkSet.FromProfile(ColorProfile.Fogra39)
    .WithSpot("PANTONE 186 C", InkAppearance.Cmyk(0, 1, 0.81f, 0.04f))
    .WithSpot("Gold", InkAppearance.Lab(new LabColor(70, 5, 60)));
using InkRaster page = new(inks, 600, 400);
using (InkCanvas canvas = new(page))
{
    canvas.Clear(InkColor.None);
    canvas.DrawImage(photo, new RectF(40, 40, 360, 240));
    canvas.FillRect(new RectF(0, 320, 600, 50), new SolidPaint(InkColor.Spot(inks[4], 1)));
    canvas.FillRect(new RectF(40, 300, 520, 4), new SolidPaint(InkColor.Spot(inks[5], 1)));
}

// Every plate as its own gray image (ink shows light), and all of them as one separated TIFF for the RIP.
foreach (InkPlate plate in page.Plates)
{
    using Raster<Gray8> gray = plate.AsRaster<Gray8>();
    gray.Save($"page-{plate.Name}.png");
}

page.Save("page.tif");

InkCanvas draws on plates with any Canvas call; RGB images and colours convert through the ink set's profile. See Printing inks.

A spot-colour label with overprint

// A label in one spot colour over a CMYK background, overprinted so the press needs no trapping: the background's
// plates stay under the spot. Black text overprints too, leaving every other plate alone where it prints.
using (InkCanvas canvas = new(page))
{
    canvas.FillRect(new RectF(0, 0, 300, 120), new SolidPaint(InkColor.Cmyk(0.1f, 0.05f, 0.4f, 0)));
    canvas.FillPath(Path.RoundRect(new RoundRect(new RectF(20, 20, 260, 80), 12)),
        new SolidPaint(InkColor.Spot(inks[4], 1)) { Overprint = Overprint.On });
    canvas.FillPath(Path.Circle(new Vector2(150, 60), 20), new SolidPaint(InkColor.Cmyk(0, 0, 0, 1)) { Overprint = Overprint.NonZero });
}

Overprint.On paints only the inks a colour names; NonZero also leaves the plates of a CMYK colour's zero components alone, as PDF's overprint mode 1.

Check total ink coverage before printing

// Before sending a page to a press with a 300% total ink limit: where is there too much ink, and how much of the page?
InkCoverage coverage = page.MeasureCoverage(limit: 3.0f);
if (coverage.MaxTotal > coverage.Limit)
{
    Console.WriteLine($"{coverage.MaxTotal:P0} at {coverage.MaxAt}; {coverage.ShareOver:P1} of the page is over {coverage.Limit:P0}.");
}

// A soft proof shows how it will look on paper, the paper's own tone included.
using Raster<Rgba8> proof = page.Proof<Rgba8>(new ProofOptions { SimulatePaper = true });