Compartir vía


Datos de trazados SVG en SkiaSharp

Definición de trazados mediante cadenas de texto en el formato Gráficos vectoriales escalables

La clase SKPath admite la definición de objetos de trazado completos a partir de cadenas de texto en un formato establecido por la especificación Gráficos vectoriales escalables (SVG). Verá más adelante en este artículo cómo puede representar un trazado completo como este en una cadena de texto:

Ruta de acceso de ejemplo definida con datos de trazado de SVG

SVG es un lenguaje de programación de gráficos basado en XML para páginas web. Como SVG debe permitir que los trazados se definan en el marcado en lugar de en una serie de llamadas de función, el estándar SVG incluye una manera extremadamente concisa de especificar un trazado gráfico completo como una cadena de texto.

Dentro de SkiaSharp, este formato se conoce como "datos de trazado SVG". El formato también se admite en entornos de programación basados en XAML de Windows, como Windows Presentation Foundation y la Plataforma universal de Windows, donde se conoce como la sintaxis de marcado de trazados o la sintaxis de comandos de movimiento y dibujo. También puede servir como formato de intercambio para imágenes de gráficos vectoriales, especialmente en archivos basados en texto, como XML.

La clase SKPath define dos métodos con las palabras SvgPathData en el nombre:

public static SKPath ParseSvgPathData(string svgPath)

public string ToSvgPathData()

El método estático ParseSvgPathData convierte una cadena en un objeto SKPath, mientras que ToSvgPathData convierte un objeto SKPath en una cadena.

Esta es una cadena SVG para una estrella de cinco puntas centrada en el punto (0, 0) con un radio de 100:

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

Las letras son comandos que crean un objeto SKPath: M indica una llamada a MoveTo, L es LineToy Z es Close para cerrar un contorno. Cada par de números proporciona una coordenada X e Y de un punto. Observe que el comando L va seguido de varios puntos separados por comas. En una serie de coordenadas y puntos, las comas y el espacio en blanco se tratan de forma idéntica. Algunos programadores prefieren colocar comas entre las coordenadas X e Y en lugar de entre los puntos, pero las comas o los espacios solo se necesitan para evitar ambigüedad. Esto es perfectamente válido:

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

La sintaxis de los datos de trazados SVG se documenta formalmente en la sección 8.3 de la especificación SVG. Este es un resumen:

MoveTo

M x y

Esto comienza un nuevo contorno en el trazado mediante el establecimiento de la posición actual. Lo datos del trazado siempre deben comenzar con un comando M.

LineTo

L x y ...

Este comando agrega una línea recta (o líneas) al trazado y establece la nueva posición actual al final de la última línea. Después del comando L puede haber varios pares de coordenadas x e y.

Horizontal LineTo

H x ...

Este comando agrega una línea horizontal al trazado y establece la nueva posición actual al final de la línea. Después del comando H puede haber varias coordenadas x, pero no tiene mucho sentido.

Vertical Line

V y ...

Este comando agrega una línea vertical al trazado y establece la nueva posición actual al final de la línea.

Cerrar

Z

Para cerrar el contorno, el comando C agrega una línea recta desde la posición actual hasta el principio del contorno.

ArcTo

El comando para agregar un arco elíptico al contorno es, sin duda, el más complejo de toda la especificación de datos de trazado de SVG. Es el único comando en el que los números pueden representar algo que no sean valores de coordenadas:

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

Los parámetros rx y ry son los radios horizontales y verticales de la elipse. rotation-angle es el sentido de las agujas del reloj en grados.

Establezca large-arc-flag en 1 para el arco grande o en 0 para el arco pequeño.

Establezca sweep-flag en 1 para el sentido de las agujas del reloj y en 0 para el sentido contrario a las agujas del reloj.

El arco se dibuja hasta el punto (x, y), que se convierte en la nueva posición actual.

CubicTo

C x1 y1 x2 y2 x3 y3 ...

Este comando agrega una curva Bézier cúbica desde la posición actual a (x3, y3), que se convierte en la nueva posición actual. Los puntos (x1, y1) y (x2, y2) son puntos de control.

Se pueden especificar varias curvas Bézier mediante un solo comando C. El número de puntos debe ser un múltiplo de 3.

También hay un comando de curva Bézier "suavizada":

S x2 y2 x3 y3 ...

Este comando debe seguir un comando Bézier normal (aunque no es estrictamente necesario). El comando Bézier suavizado calcula el primer punto de control para que sea un reflejo del segundo punto de control de la curva Bézier anterior alrededor de su punto mutuo. Por tanto, estos tres puntos son colineales, y la conexión entre las dos curvas Bézier es suave.

QuadTo

Q x1 y1 x2 y2 ...

Para curvas Bézier cuadráticas, el número de puntos debe ser un múltiplo de 2. El punto de control es (x1, y1) y el punto final (y la nueva posición actual) es (x2, y2)

También hay un comando de curva cuadrática suavizada:

T x2 y2 ...

El punto de control se calcula en función del punto de control de la curva cuadrática anterior.

Todos estos comandos también están disponibles en versiones "relativas", donde los puntos de coordenadas son relativos a la posición actual. Estos comandos relativos comienzan con letras minúsculas, por ejemplo c en lugar C de para la versión relativa del comando de curva Bézier cúbica.

Esta es la extensión de la definición de datos de trazado de SVG. No hay ninguna funcionalidad para repetir grupos de comandos ni para realizar cualquier tipo de cálculo. Los comandos para ConicTo u otros tipos de especificaciones de arco no están disponibles.

El método estático SKPath.ParseSvgPathData espera una cadena válida de comandos SVG. Si se detecta algún error de sintaxis, el método devuelve null. Es la única indicación de error.

El método ToSvgPathData es útil para obtener datos de trazados de SVG de un objeto SKPath existente para transferirlos a otro programa, o bien para almacenarlos en un formato de archivo basado en texto, como XML. (El método ToSvgPathData no se muestra en el código de ejemplo de este artículo). No espere que ToSvgPathData devuelva una cadena que se corresponda exactamente a las llamadas de método que han creado el trazado. En concreto, descubrirá que los arcos se convierten en varios comandos QuadTo y así se muestran en los datos de trazados devueltos desde ToSvgPathData.

En la página Path Data Hello se escribe la palabra "HELLO" mediante datos de trazado de SVG. Los objetos SKPath y SKPaint se definen como campos de la clase 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
    };
    ...
}

El trazado que define la cadena de texto comienza en la esquina superior izquierda, en el punto (0, 0). Cada letra tiene 50 unidades de ancho y 100 unidades de alto, y las letras están separadas por otras 25 unidades, lo que significa que todo el trazado tiene 350 unidades de ancho.

La "H" de "Hello" se compone de tres contornos de una línea, mientras que la "E" son dos curvas Bézier cúbicas conectadas. Observe que el comando C va seguido de seis puntos y dos de los puntos de control tienen las coordenadas Y -10 y 110, que los colocan fuera del intervalo de las coordenadas Y de las otras letras. La "L" está formada por dos líneas conectadas, mientras que la "O" es una elipse que se representa con un comando A.

Observe que el comando M que comienza el último contorno establece la posición en el punto (350, 50), que es el centro vertical del lado izquierdo de la "O". Como indican los primeros números después del comando A, la elipse tiene un radio horizontal de 25 y un radio vertical de 50. El punto final se indica mediante el último par de números del comando A, que representa el punto (300, 49,9). Eso es, de forma deliberada, ligeramente diferente del punto de partida. Si el punto de conexión se establece igual al punto de inicio, el arco no se representará. Para dibujar una elipse completa, debe establecer el punto de conexión cerca del punto de inicio (pero no igual a él), o bien debe usar dos o más comandos A, cada uno para una parte de la elipse completa.

Es posible que quiera agregar la siguiente instrucción al constructor de la página y, después, establecer un punto de interrupción para examinar la cadena resultante:

string str = helloPath.ToSvgPathData();

Descubrirá que el arco se ha reemplazado por una larga serie de comandos Q para obtener una aproximación por etapas del arco mediante curvas Bézier cuadráticas.

El controlador PaintSurface obtiene los límites estrechos del trazado, que no incluye los puntos de control de las curvas de la "E" y la "O". Las tres transformaciones mueven el centro del trazado punto (0, 0), escalan el trazado al tamaño del lienzo (pero también teniendo en cuenta el ancho del trazo) y, después, mueven el centro del trazado al centro del lienzo:

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);
    }
}

El trazado rellena el lienzo, que parece más razonable cuando se ve en modo horizontal:

Captura de pantalla triple de la página Datos de trazado de hola

La página Path Data Cat es similar. Los objetos de trazado y pintura se definen como campos de la clase 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
    };
    ...
}

La cabeza de un gato es un círculo, y aquí se representa con dos comandos A, cada uno dibuja un semicírculo. Los dos comandos A para la cabeza definen radios horizontales y verticales de 100. El primer arco comienza en (240, 100) y termina en (240, 300), que se convierte en el punto inicial del segundo arco que termina en (240, 100).

Los dos ojos también se representan con dos comandos A, y como con la cabeza del gato, el segundo comando A termina en el mismo punto que el inicio del primer comando A. Pero estos pares de comandos A no definen una elipse. El ancho de cada arco es de 40 unidades y el radio también es de 40 unidades, lo que significa que estos arcos no son semicírculos completos.

El controlador PaintSurface realiza transformaciones similares a las de la muestra anterior, pero establece un único factor Scale para mantener la relación de aspecto y proporcionar un poco de margen para que los bigotes del gato no toquen los lados de la pantalla:

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);
    }
}

Esta es la ejecución del programa:

Captura de pantalla triple de la página Datos de trazado de gato

Normalmente, cuando un objeto SKPath se define como un campo, los contornos del trazado se deben definir en el constructor u otro método. Pero al usar datos de trazado de SVG, ha visto que el trazado se puede especificar completamente en la definición de campo.

En el ejemplo Ugly Analog Clock anterior del artículo La transformación Rotar se han mostrado las manecillas del reloj como líneas simples. En el programa Pretty Analog Clock siguiente se reemplazan esas líneas por objetos SKPath definidos como campos en la clase PrettyAnalogClockPage junto con objetos 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
    };
    ...
}

Ahora las manecillas de la hora y los minutos ahora tienen áreas cerradas. Para que esas manecillas sean distintas entre sí, se dibujan con un contorno negro y un relleno gris mediante los objetos handStrokePaint y handFillPaint.

En el ejemplo Ugly Analog Clock anterior, los pequeños círculos que marcaban las horas y minutos se dibujaban en un bucle. En este ejemplo Pretty Analog Clock, se usa un enfoque completamente diferente: las marcas de hora y minuto son líneas de puntos dibujadas con los objetos minuteMarkPaint y 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)
    };
    ...
}

En el artículo Puntos y guiones se describe cómo puede usar el método SKPathEffect.CreateDash para crear una línea discontinua. El primer argumento es una matriz float que generalmente tiene dos elementos: el primero es la longitud de los guiones y el segundo es el espacio entre los guiones. Cuando la propiedad StrokeCap se establece en SKStrokeCap.Round, los extremos redondeados del guión aumentan eficazmente la longitud del guión por el ancho del trazo en ambos lados del guión. Por tanto, al establecer el primer elemento de la matriz en 0 se crea una línea de puntos.

La distancia entre estos puntos se rige por el segundo elemento de la matriz. Como verá en breve, estos dos objetos SKPaint se usan para dibujar círculos con un radio de 90 unidades. Por tanto, la circunferencia de este círculo es 180π, lo que significa que las marcas de 60 minutos deben aparecer cada 3π unidades, que es el segundo valor de la matriz float en minuteMarkPaint. Las marcas de 12 horas deben aparecer cada 15π unidades, que es el valor de la segunda matriz float.

La clase PrettyAnalogClockPage establece un temporizador para invalidar la superficie cada 16 milisegundos y se llama al controlador PaintSurface con esa frecuencia. Las definiciones anteriores de los objetos SKPath y SKPaint permiten código de dibujo muy limpio:

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();
    }
}

Pero algo especial sucede con la segunda manecilla. Como el reloj se actualiza cada 16 milisegundos, la propiedad Millisecond del valor DateTime se podría usar para animar una segunda manecilla en lugar de una que se mueva en saltos discretos de segundo a segundo. Pero este código no permite que el movimiento sea suave. En su lugar, usa las funciones de aceleración de animación Xamarin.FormsSpringIn y SpringOut para un tipo de movimiento diferente. Estas funciones de aceleración hacen que la segunda manecilla se mueva de una manera más entrecortada: se retrae ligeramente antes de moverse y, después, sobrepasa su destino, un efecto que desafortunadamente no se puede reproducir en estas capturas de pantalla estáticas:

Captura de pantalla triple de la página Reloj analógico bonito