SkiaSharp の SVG パス データ

スケーラブル ベクター グラフィックス形式のテキスト文字列を使用してパスを定義する

SKPath クラスは、スケーラブル ベクター グラフィックス (SVG) 仕様によって確立された形式のテキスト文字列からのパス オブジェクト全体の定義をサポートします。 テキスト文字列で次のようなパス全体を表す方法については、この記事の後半で説明します。

SVG パス データで定義されたサンプル パス

SVG は、Web ページ用の XML ベースのグラフィックス プログラミング言語です。 SVG は一連の関数呼び出しではなく、マークアップでパスを定義できるようにする必要があるため、SVG 標準には、グラフィックス パス全体をテキスト文字列として指定する非常に簡潔な方法が含まれています。

SkiaSharp 内で、この形式は "SVG パス データ" と呼ばれます。この形式は、Windows Presentation Foundation やユニバーサル Windows プラットフォームなどの Windows XAML ベースのプログラミング環境でもサポートされています。そこでは、パス マークアップ構文または移動と描画のコマンド構文と呼ばれます。 また、ベクター グラフィックス イメージの交換形式として、特に XML などのテキスト ベースのファイルで使用することもできます。

この SKPath クラスは、名前に単語 SvgPathData を含む 2 つのメソッドを定義します。

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 オブジェクトを作成するコマンドです。MMoveTo 呼び出しを示し、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 ...

このコマンドは、パスに水平線を追加し、新しい現在の位置を線の末尾に設定します。 複数の x 座標とともに H コマンドにたどることができますが、あまり意味がありません。

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 の倍数である必要があります。

また、"smooth" (滑らか) なベジエ曲線コマンドもあります。

S x2 y2 x3 y3 ...

このコマンドは通常のベジエ コマンドに続く必要があります (厳密には必須ではありません)。 滑らかなベジエ コマンドは、前のベジエの相互点の周囲にある 2 番目の制御点の反射になるように、最初の制御点を計算します。 したがって、この 3 つの点は共線であり、2 つのベジエ曲線間の接続は滑らかです。

QuadTo

Q x1 y1 x2 y2 ...

2 次ベジエ曲線の場合、点の数は 2 の倍数である必要があります。 制御点は (x1, y1) で、終点 (および新しい現在の位置) は (x2, y2) です

次のような滑らかな 2 次曲線コマンドもあります。

T x2 y2 ...

制御点は、前の 2 次曲線の制御点に基づいて計算されます。

これらのコマンドはすべて、座標点が現在の位置に対して相対的な "相対" バージョンでも使用できます。 これらの相対コマンドは、小文字で始まります。たとえば、3 次ベジエ コマンドの相対バージョンの場合は、C ではなく c になります。

これは、SVG パス データ定義の範囲です。 コマンドのグループを繰り返したり、任意の種類の計算を実行したりするための機能はありません。 ConicTo またはその他の種類の円弧仕様のコマンドは使用できません。

SKPath.ParseSvgPathData 静的メソッドでは、SVG コマンドの有効な文字列が必要です。 構文エラーが検出された場合、このメソッドは null を返します。 これが唯一のエラー表示です。

この ToSvgPathData メソッドは、既存の SKPath オブジェクトから SVG パス データを取得して別のプログラムに転送したり、XML などのテキスト ベースのファイル形式で格納したりするのに便利です。 (この記事のサンプル コードでは、ToSvgPathData メソッドは示されていません)。パスを作成したメソッド呼び出しに正確に対応する文字列を ToSvgPathData が返すことを想定 "しない" でください。 特に、円弧が複数の QuadTo コマンドに変換され、ToSvgPathData から返されるパス データにどのように表示されるかがわかります。

Path Data Hello ページでは、SVG パス データを使用して "HELLO" という単語がつづられます。 SKPath オブジェクトと SKPaint オブジェクトの両方が 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" は 3 つの 1 本線の輪郭で構成され、"E" は 2 つの接続された 3 次ベジエ曲線です。 C コマンドの後に 6 つの点が続き、2 つの制御点の Y 座標が –10 と 110 で、他の文字の Y 座標の範囲外に配置されていることに注意してください。 'L' は 2 つの接続された線ですが、'O' は A コマンドでレンダリングされる省略記号です。

最後の輪郭を開始する M コマンドは、その位置を点 (350, 50) に設定します。これは、'O' の左側の垂直方向の中心です。 A コマンドに続く最初の数値で示されているように、楕円の水平方向の半径は 25 で、垂直方向の半径は 50 です。 終点は、A コマンド内の最後のペアの数値で示されます。これは、点 (300、49.9) を表します。 これは意図的に、始点とは少し異なります。 端点が始点と等しく設定されている場合、円弧はレンダリングされません。 完全な楕円を描画するには、端点を始点の近く (しかし、等しくはない) に設定するか、または、完全な楕円の一部にそれぞれ 2 つ以上の A コマンドを使用する必要があります。

ページのコンストラクターに次のステートメントを追加し、ブレークポイントを設定して結果の文字列を調べることができます。

string str = helloPath.ToSvgPathData();

2 次ベジエ曲線を使用して、円弧の段階的な近似の一連の長い Q コマンドに円弧が置き換えられていることがわかります。

PaintSurface ハンドラーはパスの狭い境界を取得します。これには、'E' 曲線と 'O' 曲線の制御点は含まれません。 3 つの変換は、パスの中心を点 (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);
    }
}

このパスはキャンバスを塗りつぶします。これは、横モードで表示するとより適切に見えます。

[Path Data Hello] ページのトリプル スクリーンショット

Path Data Cat ページも同様です。 パス オブジェクトとペイント オブジェクトはどちらも、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
    };
    ...
}

猫の頭は円で、ここでは 2 つの A コマンドでレンダリングされ、それぞれが半円を描いています。 頭の A コマンドはどちらも、水平方向と垂直方向の半径を 100 で定義します。 最初の円弧は (240, 100) で始まり、(240, 300) で終わります。これは、(240, 100) で終わる 2 番目の円弧の始点になります。

2 つの目も 2 つの A コマンドでレンダリングされ、猫の頭と同様に、2 番目の 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);
    }
}

実行中のプログラムを次に示します。

[Path Data Cat] ページのトリプル スクリーンショット

通常、SKPath オブジェクトがフィールドとして定義されている場合、パスの輪郭はコンストラクターまたは別のメソッドで定義する必要があります。 しかし、SVG パス データを使用する場合は、パスをフィールド定義で完全に指定できることを先述しました。

前の「回転変換」の記事の Ugly Analog Clock サンプルでは、時計の針が単純な線として表示されました。 以下の Pretty Analog Clock プログラムは、これらの行を、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
    };
    ...
}

短針と長針が囲まれた領域になりました。 これらの針を互いに区別するために、handStrokePaint オブジェクトと handFillPaint オブジェクトを使用して黒い枠線と灰色の塗りつぶしの両方で描画されます。

前の Ugly Analog Clock サンプルでは、時間と分をマークした小さな円がループで描画されました。 この Pretty Analog Clock サンプルでは、まったく異なるアプローチが使用されます。時間と分のマークは、次の minuteMarkPaint オブジェクトと hourMarkPaint オブジェクトで描画された点線です。

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 メソッドを使用して破線を作成する方法について説明しました。 最初の引数は、一般的に 2 つの要素を持つ float 配列です。最初の要素は破線の長さ、2 番目の要素は破線間の差です。 StrokeCap プロパティが SKStrokeCap.Round に設定されている場合、破線の端を丸めると、破線の両側のストローク幅で破線の長さを効果的に長くします。 したがって、最初の配列要素を 0 に設定すると、点線が作成されます。

これらのドット間の距離は、2 番目の配列要素によって制御されます。 すぐにご覧になるように、この 2 つの SKPaint オブジェクトは、半径が 90 単位の円を描画するために使用されます。 したがって、この円の円周は 180π で、60 分のマークは 3π 単位ごとに表示される必要があります。これは minuteMarkPaintfloat 配列の 2 番目の値です。 12 時間のマークは、15π 単位ごとに表示する必要があります。これは、2 番目の float 配列の値です。

この PrettyAnalogClockPage クラスは、16 ミリ秒ごとにサーフェイスを無効化するタイマーを設定し、その間隔で PaintSurface ハンドラーが呼び出されます。 SKPath オブジェクトと SKPaint オブジェクトの以前の定義は、非常にクリーンな描画コードが可能です。

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 アニメーションのイージング関数を使用します。 これらのイージング関数を使用すると、秒針の動きが少しぎくしゃくし、動く前に少し引き戻され、移動先を少し通り過ぎます。残念ながら、この静的なスクリーンショットでは再現できない効果です。

[Pretty Analog Clock] ページのトリプル スクリーンショット