SkiaSharp 中的 SVG 路径数据

使用可缩放矢量图形格式的文本字符串定义路径

SKPath 类支持从采用可缩放矢量图形 (SVG) 规范建立的格式的文本字符串定义整个路径对象。 本文稍后将介绍如何在文本字符串中表示整个路径,如下所示:

使用 SVG 路径数据定义的示例路径

SVG 是适用于网页的基于 XML 的图形编程语言。 由于 SVG 必须允许在标记中定义路径而不是一系列函数调用,因此 SVG 标准包含一种极其简洁的方法,用于将整个图形路径指定为文本字符串。

在 SkiaSharp 中,此格式称为“SVG 路径数据”。基于 Windows XAML 的编程环境(包括 Windows Presentation Foundation 和通用 Windows 平台)也支持该格式,在其中称为路径标记语法移动和绘制命令语法。 它还可以用作矢量图形图像的交换格式,特别是在基于文本的文件(例如 XML)中。

SKPath 类定义两个方法,这些方法的名称中包含单词 SvgPathData

public static SKPath ParseSvgPathData(string svgPath)

public string ToSvgPathData()

静态 ParseSvgPathData 方法将字符串转换为 SKPath 对象,而 ToSvgPathDataSKPath 对象转换为字符串。

下面是一个以点 (0, 0) 为中心、以 100 为半径的五角星的 SVG 字符串:

"M 0 -100 L 58.8 90.9, -95.1 -30.9, 95.1 -30.9, -58.8 80.9 Z"

这些字母是生成 SKPath 对象的命令:M 表示 MoveTo 调用,LLineToZClose,用于闭合轮廓。 每个数字对提供某个点的 X 和 Y 坐标。 请注意,L 命令后接多个以逗号分隔的点。 在一系列坐标和点中,逗号和空格的处理方式相同。 某些程序员偏好在 X 和 Y 坐标之间放置逗号,而不是在点之间放置逗号,但逗号或空格只是为了避免歧义。 这是完全合法的:

"M0-100L58.8 90.9-95.1-30.9 95.1-30.9-58.8 80.9Z"

SVG 路径数据的语法正式记录在 SVG 规范的第 8.3 节中。 下面是摘要:

MoveTo

M x y

通过设置当前位置在路径中开始创建新轮廓。 路径数据应始终以 M 命令开头。

LineTo

L x y ...

此命令向路径添加一条或多条直线,并将新的当前位置设置为最后一条线的末尾。 可以在 L 命令后面添加多对 x 和 y 坐标

Horizontal LineTo

H x ...

此命令向路径添加一条水平线,并将新的当前位置设置为该线的末尾。 可以在 H 命令后面添加多个 x 坐标,但这没有多大意义

Vertical Line

V y ...

此命令向路径添加一条垂直线,并将新的当前位置设置为该线的末尾。

Close

Z

C 命令通过添加从当前位置到轮廓起点的直线来闭合轮廓。

ArcTo

向轮廓添加椭圆弧的命令是迄今为止整个 SVG 路径数据规范中最复杂的命令。 它是可以用数字表示除坐标值以外的值的唯一命令:

A rx ry rotation-angle large-arc-flag sweep-flag x y ...

rx 和 ry 参数是椭圆的水平和垂直半径。 rotation-angle 以顺时针角度为单位

将大弧和小弧的 large-arc-flag 分别设置为 1 和 0

将 sweep-flag 设置为 1(顺时针)和 0(逆时针)

依照点 (x, y)(成为新的当前位置)画弧

CubicTo

C x1 y1 x2 y2 x3 y3 ...

此命令添加从当前位置到 (x3, y3)(成为新的当前位置)的三次贝塞尔曲线。 点 (x1, y1) 和 (x2, y2) 是控制点

可以通过单个 C 命令指定多条贝塞尔曲线。 点数必须是 3 的倍数。

还有一个“平滑”贝塞尔曲线命令:

S x2 y2 x3 y3 ...

此命令应遵循常规贝塞尔曲线命令(尽管不严格要求)。 平滑贝塞尔曲线命令计算第一个控制点,使其成为前一个贝塞尔曲线的第二个控制点围绕共同点的反射。 因此,这三个点是共线的,两条贝塞尔曲线之间的连接是平滑的。

QuadTo

Q x1 y1 x2 y2 ...

对于二次贝塞尔曲线,点数必须是 2 的倍数。 控制点为 (x1, y1),终点(和新的当前位置)为 (x2, y2)

还有一个平滑二次曲线命令:

T x2 y2 ...

控制点是根据之前二次曲线的控制点计算出的。

所有这些命令也可用于“相对”版本,其中坐标点相对于当前位置。 这些相对命令以小写字母开头,例如三次贝塞尔曲线命令的相对版本是 c 而不是 C

这是 SVG 路径数据定义的范围。 没有用于重复命令组或执行任何类型的计算的工具。 用于 ConicTo 或其他类型的弧规范的命令不可用。

静态 SKPath.ParseSvgPathData 方法需要有效的 SVG 命令字符串。 如果检测到任何语法错误,该方法将返回 null。 这是唯一的错误指示。

ToSvgPathData 方法可以方便地从现有 SKPath 对象获取 SVG 路径数据以传输到另一个程序,或以基于文本的文件格式(例如 XML)存储。 (本文的示例代码未演示 ToSvgPathData 方法。)不要期望 ToSvgPathData 返回与创建路径的方法调用完全对应的字符串。 具体而言,你会发现,弧已转换为多个 QuadTo 命令,这就是它们在从 ToSvgPathData 返回的路径数据中的显示方式。

“路径数据 Hello”页使用 SVG 路径数据拼写出单词“HELLO”SKPathSKPaint 对象都定义为 PathDataHelloPage 类中的字段:

public class PathDataHelloPage : ContentPage
{
    SKPath helloPath = SKPath.ParseSvgPathData(
        "M 0 0 L 0 100 M 0 50 L 50 50 M 50 0 L 50 100" +                // H
        "M 125 0 C 60 -10, 60 60, 125 50, 60 40, 60 110, 125 100" +     // E
        "M 150 0 L 150 100, 200 100" +                                  // L
        "M 225 0 L 225 100, 275 100" +                                  // L
        "M 300 50 A 25 50 0 1 0 300 49.9 Z");                           // O

    SKPaint paint = new SKPaint
    {
        Style = SKPaintStyle.Stroke,
        Color = SKColors.Blue,
        StrokeWidth = 10,
        StrokeCap = SKStrokeCap.Round,
        StrokeJoin = SKStrokeJoin.Round
    };
    ...
}

定义文本字符串的路径从左上角的点 (0, 0) 开始。 每个字母的宽度为 50 个单位,高度为 100 个单位,字母之间相隔 25 个单位,这意味着整个路径的宽度为 350 个单位。

“Hello”的“H”由三个单线轮廓组成,而“E”则是两条相连的三次贝塞尔曲线。 请注意,C 命令后跟六个点,其中两个控制点的 Y 坐标分别为 –10 和 110,这使得它们超出了其他字母的 Y 坐标范围。 “L”是两条相连的线,而“O”是使用 A 命令渲染的椭圆。

请注意,开始创建最后一个轮廓的 M 命令将位置设置为点 (350, 50),该点是“O”左侧的垂直中心。 如 A 命令后面的第一个数字所示,椭圆的水平半径为 25,垂直半径为 50。 终点由 A 命令中的最后一对数字表示,代表点 (300, 49.9)。 我们有意地将其设置为与起点略有不同。 如果将终点设置为等于起点,则不会渲染弧。 若要绘制完整的椭圆,必须将终点设置为接近(但不等于)起点,或者必须使用两个或更多个 A 命令,每个命令对应于完整椭圆的一部分。

可以将以下语句添加到页的构造函数中,然后设置断点来检查生成的字符串:

string str = helloPath.ToSvgPathData();

你会发现,弧已替换为一长串的 Q 命令,以便使用二次贝塞尔曲线计算弧的分段近似值。

PaintSurface 处理程序获取路径的严格边界,其中不包括“E”和“O”曲线的控制点。 这三个变换将路径的中心移动到点 (0, 0),将路径缩放为画布大小(但同时考虑了笔划宽度),然后将路径的中心移动到画布的中心:

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

        canvas.Clear();

        SKRect bounds;
        helloPath.GetTightBounds(out bounds);

        canvas.Translate(info.Width / 2, info.Height / 2);

        canvas.Scale(info.Width / (bounds.Width + paint.StrokeWidth),
                     info.Height / (bounds.Height + paint.StrokeWidth));

        canvas.Translate(-bounds.MidX, -bounds.MidY);

        canvas.DrawPath(helloPath, paint);
    }
}

路径填充了画布,在横向模式下查看时更合理:

“路径数据 Hello”页的三重屏幕截图

“猫路径数据”页与此类似。 路径和绘制对象均定义为 PathDataCatPage 类中的字段:

public class PathDataCatPage : ContentPage
{
    SKPath catPath = SKPath.ParseSvgPathData(
        "M 160 140 L 150 50 220 103" +              // Left ear
        "M 320 140 L 330 50 260 103" +              // Right ear
        "M 215 230 L 40 200" +                      // Left whiskers
        "M 215 240 L 40 240" +
        "M 215 250 L 40 280" +
        "M 265 230 L 440 200" +                     // Right whiskers
        "M 265 240 L 440 240" +
        "M 265 250 L 440 280" +
        "M 240 100" +                               // Head
        "A 100 100 0 0 1 240 300" +
        "A 100 100 0 0 1 240 100 Z" +
        "M 180 170" +                               // Left eye
        "A 40 40 0 0 1 220 170" +
        "A 40 40 0 0 1 180 170 Z" +
        "M 300 170" +                               // Right eye
        "A 40 40 0 0 1 260 170" +
        "A 40 40 0 0 1 300 170 Z");

    SKPaint paint = new SKPaint
    {
        Style = SKPaintStyle.Stroke,
        Color = SKColors.Orange,
        StrokeWidth = 5
    };
    ...
}

猫头是一个圆,此处用两个 A 命令渲染,每个命令绘制一个半圆。 针对头部的两个 A 命令都将水平和垂直半径定义为 100。 第一条弧线从 (240, 100) 开始,在 (240, 300)(成为第二条弧线的起点)处结束,而第二条弧线又在 (240, 100) 处结束。

两只眼睛也使用两个 A 命令进行渲染,与猫头一样,第二个 A 命令在与第一个 A 命令的起点相同的点处结束。 但是,这些 A 命令对不会定义椭圆。 每条弧的长度为 40 个单位,半径也为 40 个单位,这意味着这些弧不是完整的半圆。

PaintSurface 处理程序执行与上一个示例类似的变换,但设置单个 Scale 因子来保持宽高比并提供少量的边距,以便猫的胡须不会接触屏幕的两侧:

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

        canvas.Clear(SKColors.Black);

        SKRect bounds;
        catPath.GetBounds(out bounds);

        canvas.Translate(info.Width / 2, info.Height / 2);

        canvas.Scale(0.9f * Math.Min(info.Width / bounds.Width,
                                     info.Height / bounds.Height));

        canvas.Translate(-bounds.MidX, -bounds.MidY);

        canvas.DrawPath(catPath, paint);
    }
}

下面是正在运行的程序:

“路径数据 Cat”页的三重屏幕截图

通常,将 SKPath 对象定义为字段时,必须在构造函数或其他方法中定义路径的轮廓。 但是,在使用 SVG 路径数据时,你会发现路径完全可以在字段定义中指定。

前面在旋转变换一文中介绍的“丑陋的模拟时钟”示例将时钟指针显示为简单线条。 以下“漂亮的模拟时钟”程序将这些线条替换为定义为 PrettyAnalogClockPage 类中的字段的 SKPath 对象以及 SKPaint 对象

public class PrettyAnalogClockPage : ContentPage
{
    ...
    // Clock hands pointing straight up
    SKPath hourHandPath = SKPath.ParseSvgPathData(
        "M 0 -60 C   0 -30 20 -30  5 -20 L  5   0" +
                "C   5 7.5 -5 7.5 -5   0 L -5 -20" +
                "C -20 -30  0 -30  0 -60 Z");

    SKPath minuteHandPath = SKPath.ParseSvgPathData(
        "M 0 -80 C   0 -75  0 -70  2.5 -60 L  2.5   0" +
                "C   2.5 5 -2.5 5 -2.5   0 L -2.5 -60" +
                "C 0 -70  0 -75  0 -80 Z");

    SKPath secondHandPath = SKPath.ParseSvgPathData(
        "M 0 10 L 0 -80");

    // SKPaint objects
    SKPaint handStrokePaint = new SKPaint
    {
        Style = SKPaintStyle.Stroke,
        Color = SKColors.Black,
        StrokeWidth = 2,
        StrokeCap = SKStrokeCap.Round
    };

    SKPaint handFillPaint = new SKPaint
    {
        Style = SKPaintStyle.Fill,
        Color = SKColors.Gray
    };
    ...
}

时针和分针现在具有封闭区域。 为了使这些指针彼此不同,我们使用 handStrokePainthandFillPaint 对象以黑色轮廓和灰色填充绘制了它们。

在前面的“丑陋的模拟时钟”示例中,标记了小时和分钟的小圆被绘制成一个圆圈。 在“漂亮的模拟时钟”示例中,使用了一种完全不同的方法:小时和分钟标记是用 minuteMarkPainthourMarkPaint 对象绘制的虚线

public class PrettyAnalogClockPage : ContentPage
{
    ...
    SKPaint minuteMarkPaint = new SKPaint
    {
        Style = SKPaintStyle.Stroke,
        Color = SKColors.Black,
        StrokeWidth = 3,
        StrokeCap = SKStrokeCap.Round,
        PathEffect = SKPathEffect.CreateDash(new float[] { 0, 3 * 3.14159f }, 0)
    };

    SKPaint hourMarkPaint = new SKPaint
    {
        Style = SKPaintStyle.Stroke,
        Color = SKColors.Black,
        StrokeWidth = 6,
        StrokeCap = SKStrokeCap.Round,
        PathEffect = SKPathEffect.CreateDash(new float[] { 0, 15 * 3.14159f }, 0)
    };
    ...
}

点和虚线一文讨论了如何使用 SKPathEffect.CreateDash 方法创建虚线。 第一个参数是一个 float 数组,它通常有两个元素:第一个元素是虚线的长度,第二个元素是虚线之间的间隙。 当 StrokeCap 属性设置为 SKStrokeCap.Round 时,虚线的圆角末端会有效地将虚线长度延长为虚线两侧的笔画宽度。 因此,将第一个数组元素设置为 0 会创建一条虚线。

这些点之间的距离由第二个数组元素控制。 很快你就会看到,这两个 SKPaint 对象用于绘制半径为 90 个单位的圆。 因此,该圆的周长为 180π,这意味着 60 分钟标记必须每隔 3π 个单位(这是 minuteMarkPaintfloat 数组中的第二个值)出现一次。 12 小时标记必须每隔 15π 个单位(这是第二个 float 数组中的值)出现一次。

PrettyAnalogClockPage 类设置一个计时器,每隔 16 毫秒使表面失效,并以该频率调用 PaintSurface 处理程序。 前面的 SKPathSKPaint 对象定义允许使用非常干净的绘图代码:

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

        canvas.Clear();

        // Transform for 100-radius circle in center
        canvas.Translate(info.Width / 2, info.Height / 2);
        canvas.Scale(Math.Min(info.Width / 200, info.Height / 200));

        // Draw circles for hour and minute marks
        SKRect rect = new SKRect(-90, -90, 90, 90);
        canvas.DrawOval(rect, minuteMarkPaint);
        canvas.DrawOval(rect, hourMarkPaint);

        // Get time
        DateTime dateTime = DateTime.Now;

        // Draw hour hand
        canvas.Save();
        canvas.RotateDegrees(30 * dateTime.Hour + dateTime.Minute / 2f);
        canvas.DrawPath(hourHandPath, handStrokePaint);
        canvas.DrawPath(hourHandPath, handFillPaint);
        canvas.Restore();

        // Draw minute hand
        canvas.Save();
        canvas.RotateDegrees(6 * dateTime.Minute + dateTime.Second / 10f);
        canvas.DrawPath(minuteHandPath, handStrokePaint);
        canvas.DrawPath(minuteHandPath, handFillPaint);
        canvas.Restore();

        // Draw second hand
        double t = dateTime.Millisecond / 1000.0;

        if (t < 0.5)
        {
            t = 0.5 * Easing.SpringIn.Ease(t / 0.5);
        }
        else
        {
            t = 0.5 * (1 + Easing.SpringOut.Ease((t - 0.5) / 0.5));
        }

        canvas.Save();
        canvas.RotateDegrees(6 * (dateTime.Second + (float)t));
        canvas.DrawPath(secondHandPath, handStrokePaint);
        canvas.Restore();
    }
}

但是,对于秒针则执行了一些特别的操作。 由于时钟每隔 16 毫秒更新一次,因此 DateTime 值的 Millisecond 属性可用于动画显示扫动式秒针,而不是每秒离散跳跃移动的秒针。 但此代码不能实现流畅运动。 相反,它使用 Xamarin.FormsSpringInSpringOut 动画缓动函数来实现不同类型的运动。 这些缓动函数会导致秒针以一种急躁的方式移动 – 在移动之前向后拉一点,然后稍微超出其目标,遗憾的是,这种效果无法在这些静态屏幕截图中重现:

“漂亮的模拟时钟”页的三重屏幕截图