Skip to content

Commit 1b15fe9

Browse files
Merge pull request #217 from SixLabors/sw/parse-svg-path
Parse Svg Path
2 parents f3f1edf + bc53f7d commit 1b15fe9

11 files changed

Lines changed: 330 additions & 4 deletions

src/ImageSharp.Drawing/Shapes/Path.cs

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,5 +116,268 @@ SegmentInfo IPathInternals.PointAlongPath(float distance)
116116

117117
/// <inheritdoc/>
118118
IReadOnlyList<InternalPath> IInternalPathOwner.GetRingsAsInternalPath() => new[] { this.InnerPath };
119+
120+
/// <summary>
121+
/// Converts an SVG path string into an <see cref="IPath"/>.
122+
/// </summary>
123+
/// <param name="svgPath">The string containing the SVG path data.</param>
124+
/// <param name="value">
125+
/// When this method returns, contains the logic path converted from the given SVG path string; otherwise, <see langword="null"/>.
126+
/// This parameter is passed uninitialized.
127+
/// </param>
128+
/// <returns><see langword="true"/> if the input value can be parsed and converted; otherwise, <see langword="false"/>.</returns>
129+
public static bool TryParseSvgPath(string svgPath, out IPath value)
130+
=> TryParseSvgPath(svgPath.AsSpan(), out value);
131+
132+
/// <summary>
133+
/// Converts an SVG path string into an <see cref="IPath"/>.
134+
/// </summary>
135+
/// <param name="svgPath">The string containing the SVG path data.</param>
136+
/// <param name="value">
137+
/// When this method returns, contains the logic path converted from the given SVG path string; otherwise, <see langword="null"/>.
138+
/// This parameter is passed uninitialized.
139+
/// </param>
140+
/// <returns><see langword="true"/> if the input value can be parsed and converted; otherwise, <see langword="false"/>.</returns>
141+
public static bool TryParseSvgPath(ReadOnlySpan<char> svgPath, out IPath value)
142+
{
143+
value = null;
144+
145+
var builder = new PathBuilder();
146+
147+
PointF first = PointF.Empty;
148+
PointF c = PointF.Empty;
149+
PointF lastc = PointF.Empty;
150+
PointF point1;
151+
PointF point2;
152+
PointF point3;
153+
154+
char op = '\0';
155+
char previousOp = '\0';
156+
bool relative = false;
157+
while (true)
158+
{
159+
svgPath = svgPath.TrimStart();
160+
if (svgPath.Length == 0)
161+
{
162+
break;
163+
}
164+
165+
char ch = svgPath[0];
166+
if (char.IsDigit(ch) || ch == '-' || ch == '+' || ch == '.')
167+
{
168+
// Are we are the end of the string or we are at the end of the path?
169+
if (svgPath.Length == 0 || op == 'Z')
170+
{
171+
return false;
172+
}
173+
}
174+
else if (IsSeparator(ch))
175+
{
176+
svgPath = TrimSeparator(svgPath);
177+
}
178+
else
179+
{
180+
op = ch;
181+
relative = false;
182+
if (char.IsLower(op))
183+
{
184+
op = char.ToUpper(op);
185+
relative = true;
186+
}
187+
188+
svgPath = TrimSeparator(svgPath.Slice(1));
189+
}
190+
191+
switch (op)
192+
{
193+
case 'M':
194+
svgPath = FindPoint(svgPath, out point1, relative, c);
195+
builder.MoveTo(point1);
196+
previousOp = '\0';
197+
op = 'L';
198+
c = point1;
199+
break;
200+
case 'L':
201+
svgPath = FindPoint(svgPath, out point1, relative, c);
202+
builder.LineTo(point1);
203+
c = point1;
204+
break;
205+
case 'H':
206+
svgPath = FindScaler(svgPath, out float x);
207+
if (relative)
208+
{
209+
x += c.X;
210+
}
211+
212+
builder.LineTo(x, c.Y);
213+
c.X = x;
214+
break;
215+
case 'V':
216+
svgPath = FindScaler(svgPath, out float y);
217+
if (relative)
218+
{
219+
y += c.Y;
220+
}
221+
222+
builder.LineTo(c.X, y);
223+
c.Y = y;
224+
break;
225+
case 'C':
226+
svgPath = FindPoint(svgPath, out point1, relative, c);
227+
svgPath = FindPoint(svgPath, out point2, relative, c);
228+
svgPath = FindPoint(svgPath, out point3, relative, c);
229+
builder.CubicBezierTo(point1, point2, point3);
230+
lastc = point2;
231+
c = point3;
232+
break;
233+
case 'S':
234+
svgPath = FindPoint(svgPath, out point2, relative, c);
235+
svgPath = FindPoint(svgPath, out point3, relative, c);
236+
point1 = c;
237+
if (previousOp is 'C' or 'S')
238+
{
239+
point1.X -= lastc.X - c.X;
240+
point1.Y -= lastc.Y - c.Y;
241+
}
242+
243+
builder.CubicBezierTo(point1, point2, point3);
244+
lastc = point2;
245+
c = point3;
246+
break;
247+
case 'Q': // Quadratic Bezier Curve
248+
svgPath = FindPoint(svgPath, out point1, relative, c);
249+
svgPath = FindPoint(svgPath, out point2, relative, c);
250+
builder.QuadraticBezierTo(point1, point2);
251+
lastc = point1;
252+
c = point2;
253+
break;
254+
case 'T':
255+
svgPath = FindPoint(svgPath, out point2, relative, c);
256+
point1 = c;
257+
if (previousOp is 'Q' or 'T')
258+
{
259+
point1.X -= lastc.X - c.X;
260+
point1.Y -= lastc.Y - c.Y;
261+
}
262+
263+
builder.QuadraticBezierTo(point1, point2);
264+
lastc = point1;
265+
c = point2;
266+
break;
267+
case 'A':
268+
svgPath = FindScaler(svgPath, out float radiiX);
269+
svgPath = TrimSeparator(svgPath);
270+
svgPath = FindScaler(svgPath, out float radiiY);
271+
svgPath = TrimSeparator(svgPath);
272+
svgPath = FindScaler(svgPath, out float angle);
273+
svgPath = TrimSeparator(svgPath);
274+
svgPath = FindScaler(svgPath, out float largeArc);
275+
svgPath = TrimSeparator(svgPath);
276+
svgPath = FindScaler(svgPath, out float sweep);
277+
278+
svgPath = FindPoint(svgPath, out PointF point, relative, c);
279+
if (svgPath.Length > 0)
280+
{
281+
builder.ArcTo(radiiX, radiiY, angle, largeArc == 1, sweep == 1, point);
282+
c = point;
283+
}
284+
285+
break;
286+
case 'Z':
287+
builder.CloseFigure();
288+
c = first;
289+
break;
290+
case '~':
291+
svgPath = FindPoint(svgPath, out point1, relative, c);
292+
svgPath = FindPoint(svgPath, out point2, relative, c);
293+
builder.MoveTo(point1);
294+
builder.LineTo(point2);
295+
break;
296+
default:
297+
return false;
298+
}
299+
300+
if (previousOp == 0)
301+
{
302+
first = c;
303+
}
304+
305+
previousOp = op;
306+
}
307+
308+
value = builder.Build();
309+
return true;
310+
311+
static bool IsSeparator(char ch)
312+
=> char.IsWhiteSpace(ch) || ch == ',';
313+
314+
static ReadOnlySpan<char> TrimSeparator(ReadOnlySpan<char> data)
315+
{
316+
if (data.Length == 0)
317+
{
318+
return data;
319+
}
320+
321+
int idx = 0;
322+
for (; idx < data.Length; idx++)
323+
{
324+
if (!IsSeparator(data[idx]))
325+
{
326+
break;
327+
}
328+
}
329+
330+
return data.Slice(idx);
331+
}
332+
333+
static ReadOnlySpan<char> FindPoint(ReadOnlySpan<char> str, out PointF value, bool isRelative, PointF relative)
334+
{
335+
str = FindScaler(str, out float x);
336+
str = FindScaler(str, out float y);
337+
if (isRelative)
338+
{
339+
x += relative.X;
340+
y += relative.Y;
341+
}
342+
343+
value = new PointF(x, y);
344+
return str;
345+
}
346+
347+
static ReadOnlySpan<char> FindScaler(ReadOnlySpan<char> str, out float scaler)
348+
{
349+
str = TrimSeparator(str);
350+
scaler = 0;
351+
352+
for (int i = 0; i < str.Length; i++)
353+
{
354+
if (IsSeparator(str[i]) || i == str.Length)
355+
{
356+
scaler = ParseFloat(str.Slice(0, i));
357+
return str.Slice(i);
358+
}
359+
}
360+
361+
if (str.Length > 0)
362+
{
363+
scaler = ParseFloat(str);
364+
}
365+
366+
return ReadOnlySpan<char>.Empty;
367+
}
368+
369+
#if !NETCOREAPP2_1_OR_GREATER
370+
static unsafe float ParseFloat(ReadOnlySpan<char> str)
371+
{
372+
fixed (char* p = str)
373+
{
374+
return float.Parse(new string(p, 0, str.Length));
375+
}
376+
}
377+
#else
378+
static float ParseFloat(ReadOnlySpan<char> str)
379+
=> float.Parse(str);
380+
#endif
381+
}
119382
}
120383
}

src/ImageSharp.Drawing/Shapes/PathBuilder.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@ public PathBuilder MoveTo(PointF point)
108108
public PathBuilder LineTo(PointF point)
109109
=> this.AddLine(this.currentPoint, point);
110110

111+
/// <summary>
112+
/// Draws the line connecting the current the current point to the new point.
113+
/// </summary>
114+
/// <param name="x">The x.</param>
115+
/// <param name="y">The y.</param>
116+
/// <returns>The <see cref="PathBuilder"/></returns>
117+
public PathBuilder LineTo(float x, float y)
118+
=> this.LineTo(new PointF(x, y));
119+
111120
/// <summary>
112121
/// Adds the line connecting the current point to the new point.
113122
/// </summary>

tests/ImageSharp.Drawing.Tests/Drawing/FillPathTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,31 +23,31 @@ public void FillPathSVGArcs<TPixel>(TestImageProvider<TPixel> provider)
2323
pb.MoveTo(new Vector2(80, 80))
2424
.ArcTo(45, 45, 0, false, false, new Vector2(125, 125))
2525
.LineTo(new Vector2(125, 80))
26-
.LineTo(new Vector2(80, 80));
26+
.CloseFigure();
2727

2828
IPath path = pb.Build();
2929

3030
pb = new PathBuilder();
3131
pb.MoveTo(new Vector2(230, 80))
3232
.ArcTo(45, 45, 0, true, false, new Vector2(275, 125))
3333
.LineTo(new Vector2(275, 80))
34-
.LineTo(new Vector2(230, 80));
34+
.CloseFigure();
3535

3636
IPath path2 = pb.Build();
3737

3838
pb = new PathBuilder();
3939
pb.MoveTo(new Vector2(80, 230))
4040
.ArcTo(45, 45, 0, false, true, new Vector2(125, 275))
4141
.LineTo(new Vector2(125, 230))
42-
.LineTo(new Vector2(80, 230));
42+
.CloseFigure();
4343

4444
IPath path3 = pb.Build();
4545

4646
pb = new PathBuilder();
4747
pb.MoveTo(new Vector2(230, 230))
4848
.ArcTo(45, 45, 0, true, true, new Vector2(275, 275))
4949
.LineTo(new Vector2(275, 230))
50-
.LineTo(new Vector2(230, 230));
50+
.CloseFigure();
5151

5252
IPath path4 = pb.Build();
5353

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) Six Labors.
2+
// Licensed under the Apache License, Version 2.0.
3+
4+
using SixLabors.ImageSharp.Drawing.Processing;
5+
using SixLabors.ImageSharp.Drawing.Tests.TestUtilities.ImageComparison;
6+
using SixLabors.ImageSharp.PixelFormats;
7+
using Xunit;
8+
9+
namespace SixLabors.ImageSharp.Drawing.Tests.Shapes
10+
{
11+
public class SvgPath
12+
{
13+
[Theory]
14+
[WithBlankImage(110, 70, PixelTypes.Rgba32, "M20,30 L40,5 L60,30 L80, 55 L100, 30", "zag")]
15+
[WithBlankImage(110, 50, PixelTypes.Rgba32, "M20,30 Q40,5 60,30 T100,30", "wave")]
16+
[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")]
17+
[WithBlankImage(500, 400, PixelTypes.Rgba32, @"M300,200 h-150 a150,150 0 1,0 150,-150 z", "pie_small")]
18+
[WithBlankImage(500, 400, PixelTypes.Rgba32, @"M275,175 v-150 a150,150 0 0,0 -150,150 z", "pie_big")]
19+
[WithBlankImage(100, 100, PixelTypes.Rgba32, @"M50,50 L50,20 L80,50 z M40,60 L40,90 L10,60 z", "arrows")]
20+
[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")]
21+
public void RenderSvgPath<TPixel>(TestImageProvider<TPixel> provider, string svgPath, string exampleImageKey)
22+
where TPixel : unmanaged, IPixel<TPixel>
23+
{
24+
var parsed = Path.TryParseSvgPath(svgPath, out var path);
25+
Assert.True(parsed);
26+
27+
provider.RunValidatingProcessorTest(
28+
c => c.Fill(Color.White).Draw(Color.Red, 5, path),
29+
new { type = exampleImageKey },
30+
comparer: ImageComparer.TolerantPercentage(0.002f));
31+
}
32+
}
33+
}
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)