Table of Contents

Paths and Text in SkiaSharp

Explore the intersection of paths and text

In modern graphics systems, text fonts are collections of character outlines, usually defined by quadratic Bézier curves. Consequently, many modern graphics systems include a facility to convert text characters into a graphics path.

You've already seen that you can stroke the outlines of text characters as well as fill them. This allows you to display these character outlines with a particular stroke width and even a path effect as described in the Path Effects article. But it is also possible to convert a character string into an SKPath object. This means that text outlines can be used for clipping with techniques that were described in the Clipping with Paths and Regions article.

Besides using a path effect to stroke a character outline, you can also create path effects that are based on a path that is derived from a character string, and you can even combine the two effects:

Text Path Effect

In the previous article on Path Effects, you saw how the GetFillPath method of SKPaint can obtain an outline of a stroked path. You can also use this method with paths derived from character outlines.

Finally, this article demonstrates another intersection of paths and text: The DrawTextOnPath method of SKCanvas allows you to display a text string so that the baseline of the text follows a curved path.

Text to Path Conversion

The GetTextPath method of SKFont converts a character string to an SKPath object:

public SKPath GetTextPath (String text, SKPoint origin)

The origin argument indicates the starting point of the baseline of the left side of the text. It plays the same role here as the position in the DrawText method of SKCanvas. Within the path, the baseline of the left side of the text will have the coordinates specified by the origin.

The GetTextPath method is overkill if you merely want to fill or stroke the resultant path. The normal DrawText method allows you to do that. The GetTextPath method is more useful for other tasks involving paths.

One of these tasks is clipping. The Clipping Text page creates a clipping path based on the character outlines of the word "CODE." This path is stretched to the size of the page to clip a bitmap that contains an image of the Clipping Text source code:

Triple screenshot of the Clipping Text page

The ClippingTextPage class constructor loads the bitmap from the app's Resources/Raw folder using .NET MAUI's FileSystem API:

public class ClippingTextPage : ContentPage
{
    SKBitmap? bitmap;

    public ClippingTextPage()
    {
        Title = "Clipping Text";

        SKCanvasView canvasView = new SKCanvasView();
        canvasView.PaintSurface += OnCanvasViewPaintSurface;
        Content = canvasView;

        _ = LoadBitmapAsync();
    }

    async Task LoadBitmapAsync()
    {
        using Stream stream = await FileSystem.OpenAppPackageFileAsync("PageOfCode.png");
        bitmap = SKBitmap.Decode(stream);
    }
    ...
}

The PaintSurface handler begins by creating an SKFont object suitable for text. The Typeface property is set as well as the Size, although for this particular application the Size property is purely arbitrary.

The Size property is not critical because this SKFont object is used solely for the GetTextPath call using the text string "CODE". The handler then measures the resultant SKPath object and applies three transforms to center it and scale it to the size of the page. The path can then be set as the clipping path:

public class ClippingTextPage : ContentPage
{
    ...
    void OnCanvasViewPaintSurface(object? sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;

        canvas.Clear(SKColors.Blue);

        using (SKFont font = new SKFont())
        {
            font.Typeface = SKTypeface.FromFamilyName(null, SKFontStyle.Bold);
            font.Size = 10;

            using (SKPath textPath = font.GetTextPath("CODE", new SKPoint(0, 0)))
            {
                // Set transform to center and enlarge clip path to window height
                SKRect bounds;
                textPath.GetTightBounds(out bounds);

                canvas.Translate(info.Width / 2, info.Height / 2);
                canvas.Scale(info.Width / bounds.Width, info.Height / bounds.Height);
                canvas.Translate(-bounds.MidX, -bounds.MidY);

                // Set the clip path
                canvas.ClipPath(textPath);
            }
        }

        // Reset transforms
        canvas.ResetMatrix();

        // Display bitmap to fill window but maintain aspect ratio
        SKRect rect = new SKRect(0, 0, info.Width, info.Height);
        canvas.DrawBitmap(bitmap,
            rect.AspectFill(new SKSize(bitmap.Width, bitmap.Height)));
    }
}

Once the clipping path is set, the bitmap can be displayed, and it will be clipped to the character outlines. Notice the use of the AspectFill method of SKRect that calculates a rectangle for filling the page while preserving the aspect ratio.

The Text Path Effect page converts a single ampersand character to a path to create a 1D path effect. A paint object with this path effect is then used to stroke the outline of a larger version of that same character:

Triple screenshot of the Text Path Effect page

Much of the work in the TextPathEffectPath class occurs in the fields and constructor. The SKFont and two SKPaint objects defined as fields are used for different purposes: The font (named textPathFont) with a Size of 50 is used to convert the ampersand to a path for the 1D path effect. The first paint (textPathPaint) is used for measuring text. The second (textPaint) is used to display the larger version of the ampersand with that path effect. For that reason, the Style of this second paint object is set to Stroke, but the StrokeWidth property is not set because that property isn't necessary when using a 1D path effect:

public class TextPathEffectPage : ContentPage
{
    const string character = "@";
    const float littleSize = 50;

    SKPathEffect? pathEffect;

    SKFont textPathFont = new SKFont
    {
        Size = littleSize
    };

    SKPaint textPathPaint = new SKPaint();

    SKPaint textPaint = new SKPaint
    {
        Style = SKPaintStyle.Stroke,
        Color = SKColors.Black
    };

    public TextPathEffectPage()
    {
        Title = "Text Path Effect";

        SKCanvasView canvasView = new SKCanvasView();
        canvasView.PaintSurface += OnCanvasViewPaintSurface;
        Content = canvasView;

        // Get the bounds of textPathFont
        textPathFont.MeasureText(character, out SKRect textPathPaintBounds);

        // Create textPath centered around (0, 0)
        SKPath textPath = textPathFont.GetTextPath(character,
                                                    new SKPoint(-textPathPaintBounds.MidX, -textPathPaintBounds.MidY));
        // Create the path effect
        pathEffect = SKPathEffect.Create1DPath(textPath, littleSize, 0,
                                               SKPath1DPathEffectStyle.Translate);
    }
    ...
}

The constructor first uses the textPathFont object to measure the ampersand with a Size of 50. The negatives of the center coordinates of that rectangle are then passed to the GetTextPath method as an SKPoint to convert the text to a path. The resultant path has the (0, 0) point in the center of the character, which is ideal for a 1D path effect.

You might think that the SKPathEffect object created at the end of the constructor could be set to the PathEffect property of textPaint rather than saved as a field. But this turned out not to work very well because it distorted the results of the MeasureText call in the PaintSurface handler:

public class TextPathEffectPage : ContentPage
{
    ...
    void OnCanvasViewPaintSurface(object? sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;

        canvas.Clear();

        // Set textFont Size based on screen size
        textFont.Size = Math.Min(info.Width, info.Height);

        // Do not measure the text with PathEffect set!
        textFont.MeasureText(character, out SKRect textBounds);

        // Coordinates to center text on screen
        float xText = info.Width / 2 - textBounds.MidX;
        float yText = info.Height / 2 - textBounds.MidY;

        // Set the PathEffect property and display text
        textPaint.PathEffect = pathEffect;
        canvas.DrawText(character, xText, yText, SKTextAlign.Left, textFont, textPaint);
    }
}

That MeasureText call is used to center the character on the page. To avoid problems, the PathEffect property is set to the paint object after the text has been measured but before it is displayed.

Outlines of Character Outlines

Normally the GetFillPath method of SKPaint converts one path to another by applying paint properties, most notably the stroke width and path effect. When used without path effects, GetFillPath effectively creates a path that outlines another path. This was demonstrated in the Tap to Outline the Path page in the Path Effects article.

You can also call GetFillPath on the path returned from GetTextPath but at first you might not be entirely sure what that would look like.

The Character Outline Outlines page demonstrates the technique. All the relevant code is in the PaintSurface handler of the CharacterOutlineOutlinesPage class.

The code begins by creating an SKFont object with a Size property based on the size of the page. This is converted to a path using the GetTextPath method. The SKPoint argument to GetTextPath effectively centers the path on the screen:

void OnCanvasViewPaintSurface(object? sender, SKPaintSurfaceEventArgs args)
{
    SKImageInfo info = args.Info;
    SKSurface surface = args.Surface;
    SKCanvas canvas = surface.Canvas;

    canvas.Clear();

    using (SKPaint textPaint = new SKPaint())
    using (SKFont textFont = new SKFont())
    {
        // Set Style for the character outlines
        textPaint.Style = SKPaintStyle.Stroke;

        // Set Size based on screen size
        textFont.Size = Math.Min(info.Width, info.Height);

        // Measure the text
        textFont.MeasureText("@", out SKRect textBounds);

        // Coordinates to center text on screen
        float xText = info.Width / 2 - textBounds.MidX;
        float yText = info.Height / 2 - textBounds.MidY;

        // Get the path for the character outlines
        using (SKPath textPath = textFont.GetTextPath("@", new SKPoint(xText, yText)))
        {
            // Create a new path for the outlines of the path
            using (SKPath outlinePath = new SKPath())
            {
                // Convert the path to the outlines of the stroked path
                textPaint.StrokeWidth = 25;
                textPaint.GetFillPath(textPath, outlinePath);

                // Stroke that new path
                using (SKPaint outlinePaint = new SKPaint())
                {
                    outlinePaint.Style = SKPaintStyle.Stroke;
                    outlinePaint.StrokeWidth = 5;
                    outlinePaint.Color = SKColors.Red;

                    canvas.DrawPath(outlinePath, outlinePaint);
                }
            }
        }
    }
}

The PaintSurface handler then creates a new path named outlinePath. This becomes the destination path in the call to GetFillPath. The StrokeWidth property of 25 causes outlinePath to describe the outline of a 25-pixel-wide path stroking the text characters. This path is then displayed in red with a stroke width of 5:

Triple screenshot of the Character Outline Outlines page

Look closely and you'll see overlaps where the path outline makes a sharp corner. These are normal artifacts of this process.

Text Along a Path

Text is normally displayed on a horizontal baseline. Text can be rotated to run vertically or diagonally, but the baseline is still a straight line.

There are times, however, when you want text to run along a curve. This is the purpose of the DrawTextOnPath method of SKCanvas:

public Void DrawTextOnPath (String text, SKPath path, Single hOffset, Single vOffset, SKPaint paint)

The text specified in the first argument is made to run along the path specified as the second argument. You can begin the text at an offset from the beginning of the path with the hOffset argument. Normally the path forms the baseline of the text: Text ascenders are on one side of the path, and text descenders are on the other. But you can offset the text baseline from the path with the vOffset argument.

This method has no facility to provide guidance on setting the TextSize property of SKPaint to make the text sized perfectly to run from the beginning of the path to the end. Sometimes you can figure out that text size on your own. Other times you'll need to use path-measuring functions to be described in the next article on Path Information and Enumeration.

The Circular Text program wraps text around a circle. It's easy to determine the circumference of a circle, so it's easy to size the text to fit exactly. The PaintSurface handler of the CircularTextPage class calculates a radius of a circle based on the size of the page. That circle becomes circularPath:

public class CircularTextPage : ContentPage
{
    const string text = "xt in a circle that shapes the te";
    ...
    void OnCanvasViewPaintSurface(object? sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;

        canvas.Clear();

        using (SKPath circularPath = new SKPath())
        {
            float radius = 0.35f * Math.Min(info.Width, info.Height);
            circularPath.AddCircle(info.Width / 2, info.Height / 2, radius);

            using (SKPaint textPaint = new SKPaint())
            using (SKFont textFont = new SKFont { Size = 100 })
            {
                float textWidth = textFont.MeasureText(text);
                textFont.Size *= 2 * 3.14f * radius / textWidth;

                canvas.DrawTextOnPath(text, circularPath, 0, 0, textFont, textPaint);
            }
        }
    }
}

The Size property of textFont is then adjusted so that the text width matches the circumference of the circle:

Triple screenshot of the Circular Text page

The text itself was chosen to be somewhat circular as well: The word "circle" is both the subject of the sentence and the object of a prepositional phrase.