Skip to content

Commit

Permalink
Merge pull request #217 from SixLabors/sw/parse-svg-path
Browse files Browse the repository at this point in the history
Parse Svg Path
  • Loading branch information
JimBobSquarePants authored Apr 15, 2022
2 parents f3f1edf + bc53f7d commit 1b15fe9
Show file tree
Hide file tree
Showing 11 changed files with 330 additions and 4 deletions.
263 changes: 263 additions & 0 deletions src/ImageSharp.Drawing/Shapes/Path.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,268 @@ SegmentInfo IPathInternals.PointAlongPath(float distance)

/// <inheritdoc/>
IReadOnlyList<InternalPath> IInternalPathOwner.GetRingsAsInternalPath() => new[] { this.InnerPath };

/// <summary>
/// Converts an SVG path string into an <see cref="IPath"/>.
/// </summary>
/// <param name="svgPath">The string containing the SVG path data.</param>
/// <param name="value">
/// When this method returns, contains the logic path converted from the given SVG path string; otherwise, <see langword="null"/>.
/// This parameter is passed uninitialized.
/// </param>
/// <returns><see langword="true"/> if the input value can be parsed and converted; otherwise, <see langword="false"/>.</returns>
public static bool TryParseSvgPath(string svgPath, out IPath value)
=> TryParseSvgPath(svgPath.AsSpan(), out value);

/// <summary>
/// Converts an SVG path string into an <see cref="IPath"/>.
/// </summary>
/// <param name="svgPath">The string containing the SVG path data.</param>
/// <param name="value">
/// When this method returns, contains the logic path converted from the given SVG path string; otherwise, <see langword="null"/>.
/// This parameter is passed uninitialized.
/// </param>
/// <returns><see langword="true"/> if the input value can be parsed and converted; otherwise, <see langword="false"/>.</returns>
public static bool TryParseSvgPath(ReadOnlySpan<char> svgPath, out IPath value)
{
value = null;

var builder = new PathBuilder();

PointF first = PointF.Empty;
PointF c = PointF.Empty;
PointF lastc = PointF.Empty;
PointF point1;
PointF point2;
PointF point3;

char op = '\0';
char previousOp = '\0';
bool relative = false;
while (true)
{
svgPath = svgPath.TrimStart();
if (svgPath.Length == 0)
{
break;
}

char ch = svgPath[0];
if (char.IsDigit(ch) || ch == '-' || ch == '+' || ch == '.')
{
// Are we are the end of the string or we are at the end of the path?
if (svgPath.Length == 0 || op == 'Z')
{
return false;
}
}
else if (IsSeparator(ch))
{
svgPath = TrimSeparator(svgPath);
}
else
{
op = ch;
relative = false;
if (char.IsLower(op))
{
op = char.ToUpper(op);
relative = true;
}

svgPath = TrimSeparator(svgPath.Slice(1));
}

switch (op)
{
case 'M':
svgPath = FindPoint(svgPath, out point1, relative, c);
builder.MoveTo(point1);
previousOp = '\0';
op = 'L';
c = point1;
break;
case 'L':
svgPath = FindPoint(svgPath, out point1, relative, c);
builder.LineTo(point1);
c = point1;
break;
case 'H':
svgPath = FindScaler(svgPath, out float x);
if (relative)
{
x += c.X;
}

builder.LineTo(x, c.Y);
c.X = x;
break;
case 'V':
svgPath = FindScaler(svgPath, out float y);
if (relative)
{
y += c.Y;
}

builder.LineTo(c.X, y);
c.Y = y;
break;
case 'C':
svgPath = FindPoint(svgPath, out point1, relative, c);
svgPath = FindPoint(svgPath, out point2, relative, c);
svgPath = FindPoint(svgPath, out point3, relative, c);
builder.CubicBezierTo(point1, point2, point3);
lastc = point2;
c = point3;
break;
case 'S':
svgPath = FindPoint(svgPath, out point2, relative, c);
svgPath = FindPoint(svgPath, out point3, relative, c);
point1 = c;
if (previousOp is 'C' or 'S')
{
point1.X -= lastc.X - c.X;
point1.Y -= lastc.Y - c.Y;
}

builder.CubicBezierTo(point1, point2, point3);
lastc = point2;
c = point3;
break;
case 'Q': // Quadratic Bezier Curve
svgPath = FindPoint(svgPath, out point1, relative, c);
svgPath = FindPoint(svgPath, out point2, relative, c);
builder.QuadraticBezierTo(point1, point2);
lastc = point1;
c = point2;
break;
case 'T':
svgPath = FindPoint(svgPath, out point2, relative, c);
point1 = c;
if (previousOp is 'Q' or 'T')
{
point1.X -= lastc.X - c.X;
point1.Y -= lastc.Y - c.Y;
}

builder.QuadraticBezierTo(point1, point2);
lastc = point1;
c = point2;
break;
case 'A':
svgPath = FindScaler(svgPath, out float radiiX);
svgPath = TrimSeparator(svgPath);
svgPath = FindScaler(svgPath, out float radiiY);
svgPath = TrimSeparator(svgPath);
svgPath = FindScaler(svgPath, out float angle);
svgPath = TrimSeparator(svgPath);
svgPath = FindScaler(svgPath, out float largeArc);
svgPath = TrimSeparator(svgPath);
svgPath = FindScaler(svgPath, out float sweep);

svgPath = FindPoint(svgPath, out PointF point, relative, c);
if (svgPath.Length > 0)
{
builder.ArcTo(radiiX, radiiY, angle, largeArc == 1, sweep == 1, point);
c = point;
}

break;
case 'Z':
builder.CloseFigure();
c = first;
break;
case '~':
svgPath = FindPoint(svgPath, out point1, relative, c);
svgPath = FindPoint(svgPath, out point2, relative, c);
builder.MoveTo(point1);
builder.LineTo(point2);
break;
default:
return false;
}

if (previousOp == 0)
{
first = c;
}

previousOp = op;
}

value = builder.Build();
return true;

static bool IsSeparator(char ch)
=> char.IsWhiteSpace(ch) || ch == ',';

static ReadOnlySpan<char> TrimSeparator(ReadOnlySpan<char> data)
{
if (data.Length == 0)
{
return data;
}

int idx = 0;
for (; idx < data.Length; idx++)
{
if (!IsSeparator(data[idx]))
{
break;
}
}

return data.Slice(idx);
}

static ReadOnlySpan<char> FindPoint(ReadOnlySpan<char> str, out PointF value, bool isRelative, PointF relative)
{
str = FindScaler(str, out float x);
str = FindScaler(str, out float y);
if (isRelative)
{
x += relative.X;
y += relative.Y;
}

value = new PointF(x, y);
return str;
}

static ReadOnlySpan<char> FindScaler(ReadOnlySpan<char> str, out float scaler)
{
str = TrimSeparator(str);
scaler = 0;

for (int i = 0; i < str.Length; i++)
{
if (IsSeparator(str[i]) || i == str.Length)
{
scaler = ParseFloat(str.Slice(0, i));
return str.Slice(i);
}
}

if (str.Length > 0)
{
scaler = ParseFloat(str);
}

return ReadOnlySpan<char>.Empty;
}

#if !NETCOREAPP2_1_OR_GREATER
static unsafe float ParseFloat(ReadOnlySpan<char> str)
{
fixed (char* p = str)
{
return float.Parse(new string(p, 0, str.Length));
}
}
#else
static float ParseFloat(ReadOnlySpan<char> str)
=> float.Parse(str);
#endif
}
}
}
9 changes: 9 additions & 0 deletions src/ImageSharp.Drawing/Shapes/PathBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@ public PathBuilder MoveTo(PointF point)
public PathBuilder LineTo(PointF point)
=> this.AddLine(this.currentPoint, point);

/// <summary>
/// Draws the line connecting the current the current point to the new point.
/// </summary>
/// <param name="x">The x.</param>
/// <param name="y">The y.</param>
/// <returns>The <see cref="PathBuilder"/></returns>
public PathBuilder LineTo(float x, float y)
=> this.LineTo(new PointF(x, y));

/// <summary>
/// Adds the line connecting the current point to the new point.
/// </summary>
Expand Down
8 changes: 4 additions & 4 deletions tests/ImageSharp.Drawing.Tests/Drawing/FillPathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,31 +23,31 @@ public void FillPathSVGArcs<TPixel>(TestImageProvider<TPixel> provider)
pb.MoveTo(new Vector2(80, 80))
.ArcTo(45, 45, 0, false, false, new Vector2(125, 125))
.LineTo(new Vector2(125, 80))
.LineTo(new Vector2(80, 80));
.CloseFigure();

IPath path = pb.Build();

pb = new PathBuilder();
pb.MoveTo(new Vector2(230, 80))
.ArcTo(45, 45, 0, true, false, new Vector2(275, 125))
.LineTo(new Vector2(275, 80))
.LineTo(new Vector2(230, 80));
.CloseFigure();

IPath path2 = pb.Build();

pb = new PathBuilder();
pb.MoveTo(new Vector2(80, 230))
.ArcTo(45, 45, 0, false, true, new Vector2(125, 275))
.LineTo(new Vector2(125, 230))
.LineTo(new Vector2(80, 230));
.CloseFigure();

IPath path3 = pb.Build();

pb = new PathBuilder();
pb.MoveTo(new Vector2(230, 230))
.ArcTo(45, 45, 0, true, true, new Vector2(275, 275))
.LineTo(new Vector2(275, 230))
.LineTo(new Vector2(230, 230));
.CloseFigure();

IPath path4 = pb.Build();

Expand Down
33 changes: 33 additions & 0 deletions tests/ImageSharp.Drawing.Tests/Shapes/SvgPath.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) Six Labors.
// Licensed under the Apache License, Version 2.0.

using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.Drawing.Tests.TestUtilities.ImageComparison;
using SixLabors.ImageSharp.PixelFormats;
using Xunit;

namespace SixLabors.ImageSharp.Drawing.Tests.Shapes
{
public class SvgPath
{
[Theory]
[WithBlankImage(110, 70, PixelTypes.Rgba32, "M20,30 L40,5 L60,30 L80, 55 L100, 30", "zag")]
[WithBlankImage(110, 50, PixelTypes.Rgba32, "M20,30 Q40,5 60,30 T100,30", "wave")]
[WithBlankImage(500, 400, PixelTypes.Rgba32, @"M10,350 l 50,-25 a25,25 -30 0,1 50,-25 l 50,-25 a25,50 -30 0,1 50,-25 l 50,-25 a25,75 -30 0,1 50,-25 l 50,-25 a25,100 -30 0,1 50,-25 l 50,-25", "bumpy")]
[WithBlankImage(500, 400, PixelTypes.Rgba32, @"M300,200 h-150 a150,150 0 1,0 150,-150 z", "pie_small")]
[WithBlankImage(500, 400, PixelTypes.Rgba32, @"M275,175 v-150 a150,150 0 0,0 -150,150 z", "pie_big")]
[WithBlankImage(100, 100, PixelTypes.Rgba32, @"M50,50 L50,20 L80,50 z M40,60 L40,90 L10,60 z", "arrows")]
[WithBlankImage(500, 400, PixelTypes.Rgba32, @"M 10 315 L 110 215 A 30 50 0 0 1 162.55 162.45 L 172.55 152.45 A 30 50 -45 0 1 215.1 109.9 L 315 10", "chopped_oval")]
public void RenderSvgPath<TPixel>(TestImageProvider<TPixel> provider, string svgPath, string exampleImageKey)
where TPixel : unmanaged, IPixel<TPixel>
{
var parsed = Path.TryParseSvgPath(svgPath, out var path);
Assert.True(parsed);

provider.RunValidatingProcessorTest(
c => c.Fill(Color.White).Draw(Color.Red, 5, path),
new { type = exampleImageKey },
comparer: ImageComparer.TolerantPercentage(0.002f));
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 1b15fe9

Please sign in to comment.