// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Numerics;
namespace SixLabors.ImageSharp.Drawing {
///
/// A aggregate of s making a single logical path.
///
///
public class Path : IPath, ISimplePath, IPathInternals, IInternalPathOwner
{
private readonly ILineSegment[] lineSegments;
private InternalPath? innerPath;
private IReadOnlyList? internalPathRings;
private IPath? closedPath;
private LinearGeometryCache geometryCache;
private RectangleF? bounds;
///
/// Initializes a new instance of the class.
///
/// The collection of points; processed as a series of linear line segments.
public Path(PointF[] points)
: this(new LinearLineSegment(points))
{
}
///
/// Initializes a new instance of the class.
///
/// The segments.
public Path(IEnumerable segments)
: this(GetSegmentArray(segments))
{
}
///
/// Initializes a new instance of the class.
///
/// The path.
public Path(Path path)
: this(path.LineSegments)
{
}
///
/// Initializes a new instance of the class.
///
/// The segments.
public Path(params ILineSegment[] segments)
{
Guard.NotNull(segments, nameof(segments));
this.lineSegments = segments;
}
///
/// Gets the default empty path.
///
public static IPath Empty { get; } = EmptyPath.OpenPath;
///
bool ISimplePath.IsClosed => this.IsClosed;
///
public virtual bool IsClosed => false;
///
public ReadOnlyMemory Points => this.InnerPath.Points();
///
public RectangleF Bounds => this.bounds ??= this.CalculateBounds();
///
public PathTypes PathType => this.IsClosed ? PathTypes.Closed : PathTypes.Open;
///
/// Gets the maximum number intersections that a shape can have when testing a line.
///
internal int MaxIntersections => this.InnerPath.PointCount;
///
/// Gets readonly collection of line segments.
///
public IReadOnlyList LineSegments => this.lineSegments;
///
/// Gets or sets a value indicating whether close or collinear vertices should be removed. TEST ONLY!
///
internal bool RemoveCloseAndCollinearPoints { get; set; } = true;
private protected InternalPath InnerPath =>
this.innerPath ??= new InternalPath(this.lineSegments, this.IsClosed, this.RemoveCloseAndCollinearPoints);
///
public virtual IPath Transform(Matrix4x4 matrix)
{
if (matrix.IsIdentity)
{
return this;
}
ILineSegment[] segments = new ILineSegment[this.lineSegments.Length];
for (int i = 0; i < segments.Length; i++)
{
segments[i] = this.lineSegments[i].Transform(matrix);
}
return new Path(segments);
}
///
public IPath AsClosedPath()
{
if (this.IsClosed)
{
return this;
}
return this.closedPath ??= new Polygon(this.LineSegments);
}
///
public IEnumerable Flatten()
{
yield return this;
}
///
public virtual LinearGeometry ToLinearGeometry(Vector2 scale)
=> this.geometryCache.TryGet(scale, out LinearGeometry? hit)
? hit
: this.geometryCache.Store(scale, this.BuildLinearGeometry(scale));
private LinearGeometry BuildLinearGeometry(Vector2 scale)
{
if (this.lineSegments.Length == 0)
{
return new LinearGeometry(
new LinearGeometryInfo
{
Bounds = RectangleF.Empty,
ContourCount = 0,
PointCount = 0,
SegmentCount = 0,
NonHorizontalSegmentCountPixelBoundary = 0,
NonHorizontalSegmentCountPixelCenter = 0
},
[],
[]);
}
PointF? lastEndPoint = null;
int pointCount = 0;
for (int i = 0; i < this.lineSegments.Length; i++)
{
ILineSegment segment = this.lineSegments[i];
bool skipFirstPoint = lastEndPoint?.Equals(segment.StartPoint) == true;
pointCount += segment.LinearVertexCount(scale) - (skipFirstPoint ? 1 : 0);
lastEndPoint = segment.EndPoint;
}
PointF[] points = new PointF[pointCount];
LinearContour[] contours = pointCount == 0 ? [] : new LinearContour[1];
bool hasBounds = false;
float minX = float.MaxValue;
float minY = float.MaxValue;
float maxX = float.MinValue;
float maxY = float.MinValue;
int nonHorizontalSegmentCountPixelBoundary = 0;
int nonHorizontalSegmentCountPixelCenter = 0;
int pointIndex = 0;
lastEndPoint = null;
for (int i = 0; i < this.lineSegments.Length; i++)
{
ILineSegment segment = this.lineSegments[i];
bool skipFirstPoint = lastEndPoint?.Equals(segment.StartPoint) == true;
int contributionCount = segment.LinearVertexCount(scale) - (skipFirstPoint ? 1 : 0);
Span destination = points.AsSpan(pointIndex, contributionCount);
segment.CopyTo(destination, skipFirstPoint, scale);
lastEndPoint = segment.EndPoint;
for (int p = 0; p < destination.Length; p++)
{
PointF point = destination[p];
minX = MathF.Min(minX, point.X);
minY = MathF.Min(minY, point.Y);
maxX = MathF.Max(maxX, point.X);
maxY = MathF.Max(maxY, point.Y);
hasBounds = true;
}
pointIndex += contributionCount;
}
int segmentCount = pointCount == 0 ? 0 : this.IsClosed ? pointCount : pointCount - 1;
CountNonHorizontalSegments(points, pointCount, this.IsClosed, ref nonHorizontalSegmentCountPixelBoundary, ref nonHorizontalSegmentCountPixelCenter);
if (pointCount > 0)
{
contours[0] = new LinearContour
{
PointStart = 0,
PointCount = pointCount,
SegmentStart = 0,
SegmentCount = segmentCount,
IsClosed = this.IsClosed
};
}
RectangleF bounds = hasBounds ? RectangleF.FromLTRB(minX, minY, maxX, maxY) : RectangleF.Empty;
return new LinearGeometry(
new LinearGeometryInfo
{
Bounds = bounds,
ContourCount = contours.Length,
PointCount = points.Length,
SegmentCount = segmentCount,
NonHorizontalSegmentCountPixelBoundary = nonHorizontalSegmentCountPixelBoundary,
NonHorizontalSegmentCountPixelCenter = nonHorizontalSegmentCountPixelCenter
},
contours,
points);
}
///
SegmentInfo IPathInternals.PointAlongPath(float distance)
=> this.InnerPath.PointAlongPath(distance);
///
IReadOnlyList IInternalPathOwner.GetRingsAsInternalPath()
=> this.internalPathRings ??= [this.InnerPath];
///
/// Computes path bounds directly from segment bounds without materializing .
///
private RectangleF CalculateBounds()
{
if (this.lineSegments.Length == 0)
{
return RectangleF.Empty;
}
RectangleF bounds = this.lineSegments[0].Bounds;
for (int i = 1; i < this.lineSegments.Length; i++)
{
bounds = RectangleF.Union(bounds, this.lineSegments[i].Bounds);
}
return bounds;
}
///
/// Materializes the segment sequence into the retained array used by the path.
///
/// The segment sequence to materialize.
/// The retained segment array.
private static ILineSegment[] GetSegmentArray(IEnumerable segments)
{
Guard.NotNull(segments, nameof(segments));
return segments as ILineSegment[] ?? [.. segments];
}
///
/// Counts how many derived segments survive as non-horizontal raster work for each sampling origin.
///
/// The retained contour point run.
/// The number of retained points in the contour.
/// Whether the contour closes back to its first point.
/// The accumulated pixel-boundary count to update.
/// The accumulated pixel-center count to update.
private static void CountNonHorizontalSegments(
ReadOnlySpan points,
int pointCount,
bool isClosed,
ref int nonHorizontalSegmentCountPixelBoundary,
ref int nonHorizontalSegmentCountPixelCenter)
{
if (pointCount <= 1)
{
return;
}
int segmentCount = isClosed ? pointCount : pointCount - 1;
for (int i = 0; i < segmentCount; i++)
{
PointF start = points[i];
PointF end = points[(i + 1) == pointCount ? 0 : i + 1];
if (ToFixedBoundary(start.Y) != ToFixedBoundary(end.Y))
{
nonHorizontalSegmentCountPixelBoundary++;
}
if (ToFixedCenter(start.Y) != ToFixedCenter(end.Y))
{
nonHorizontalSegmentCountPixelCenter++;
}
}
}
///
/// Converts a coordinate to the fixed-point row space used by boundary-sampled raster work.
///
/// The coordinate to convert.
/// The rounded 24.8 fixed-point value.
private static int ToFixedBoundary(float value) => (int)MathF.Round(value * 256F);
///
/// Converts a coordinate to the fixed-point row space used by center-sampled raster work.
///
/// The coordinate to convert.
/// The rounded 24.8 fixed-point value after the half-pixel sampling offset is applied.
private static int ToFixedCenter(float value) => (int)MathF.Round((value + 0.5F) * 256F);
///
/// Converts an SVG path string into an .
///
/// The string containing the SVG path data.
///
/// When this method returns, contains the logic path converted from the given SVG path string; otherwise, .
/// This parameter is passed uninitialized.
///
/// if the input value can be parsed and converted; otherwise, .
public static bool TryParseSvgPath(string svgPath, [NotNullWhen(true)] out IPath? value)
=> TryParseSvgPath(svgPath.AsSpan(), out value);
///
/// Converts an SVG path string into an .
///
/// The string containing the SVG path data.
///
/// When this method returns, contains the logic path converted from the given SVG path string; otherwise, .
/// This parameter is passed uninitialized.
///
/// if the input value can be parsed and converted; otherwise, .
public static bool TryParseSvgPath(ReadOnlySpan svgPath, [NotNullWhen(true)] out IPath? value)
{
value = null;
PathBuilder builder = new();
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 == '.')
{
// SVG allows repeated operand groups to reuse the previous command.
// A leading number is only valid once a drawable command is active.
if (op is '\0' or 'Z')
{
return false;
}
}
else if (IsSeparator(ch))
{
svgPath = TrimSeparator(svgPath);
}
else
{
op = ch;
relative = false;
if (char.IsLower(op))
{
op = char.ToUpper(op, CultureInfo.InvariantCulture);
relative = true;
}
svgPath = TrimSeparator(svgPath[1..]);
}
// Read every operand for the command before appending geometry. That keeps
// malformed or truncated data from leaking a partially parsed segment into the path.
switch (op)
{
case 'M':
if (!TryFindPoint(ref svgPath, relative, c, out point1))
{
return false;
}
_ = builder.MoveTo(point1);
previousOp = '\0';
// Extra coordinate pairs after a move command are implicit line commands.
op = 'L';
c = point1;
break;
case 'L':
if (!TryFindPoint(ref svgPath, relative, c, out point1))
{
return false;
}
_ = builder.LineTo(point1);
c = point1;
break;
case 'H':
if (!TryFindScaler(ref svgPath, out float x))
{
return false;
}
if (relative)
{
x += c.X;
}
if (!float.IsFinite(x))
{
return false;
}
_ = builder.LineTo(x, c.Y);
c.X = x;
break;
case 'V':
if (!TryFindScaler(ref svgPath, out float y))
{
return false;
}
if (relative)
{
y += c.Y;
}
if (!float.IsFinite(y))
{
return false;
}
_ = builder.LineTo(c.X, y);
c.Y = y;
break;
case 'C':
if (!TryFindPoint(ref svgPath, relative, c, out point1)
|| !TryFindPoint(ref svgPath, relative, c, out point2)
|| !TryFindPoint(ref svgPath, relative, c, out point3))
{
return false;
}
_ = builder.CubicBezierTo(point1, point2, point3);
lastc = point2;
c = point3;
break;
case 'S':
if (!TryFindPoint(ref svgPath, relative, c, out point2)
|| !TryFindPoint(ref svgPath, relative, c, out point3))
{
return false;
}
point1 = c;
if (previousOp is 'C' or 'S')
{
// Smooth cubic curves mirror the previous cubic control point.
// Without a preceding cubic command, the current point is the control point.
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
if (!TryFindPoint(ref svgPath, relative, c, out point1)
|| !TryFindPoint(ref svgPath, relative, c, out point2))
{
return false;
}
_ = builder.QuadraticBezierTo(point1, point2);
lastc = point1;
c = point2;
break;
case 'T':
if (!TryFindPoint(ref svgPath, relative, c, out point2))
{
return false;
}
point1 = c;
if (previousOp is 'Q' or 'T')
{
// Smooth quadratic curves mirror the previous quadratic control point.
// Without a preceding quadratic command, the current point is the control point.
point1.X -= lastc.X - c.X;
point1.Y -= lastc.Y - c.Y;
}
_ = builder.QuadraticBezierTo(point1, point2);
lastc = point1;
c = point2;
break;
case 'A':
// Arc flags are single SVG grammar tokens, not numbers. Reading them as
// scalars would accept malformed flag/end-point boundaries such as "04445".
if (!TryFindScaler(ref svgPath, out float radiiX)
|| !TryTrimSeparator(ref svgPath)
|| !TryFindScaler(ref svgPath, out float radiiY)
|| !TryTrimSeparator(ref svgPath)
|| !TryFindScaler(ref svgPath, out float angle)
|| !TryTrimSeparator(ref svgPath)
|| !TryFindFlag(ref svgPath, out bool largeArc)
|| !TryTrimSeparator(ref svgPath)
|| !TryFindFlag(ref svgPath, out bool sweep)
|| !TryFindPoint(ref svgPath, relative, c, out PointF point))
{
return false;
}
_ = builder.ArcTo(radiiX, radiiY, angle, largeArc, sweep, point);
c = point;
break;
case 'Z':
_ = builder.CloseFigure();
c = first;
break;
case '~':
if (!TryFindPoint(ref svgPath, relative, c, out point1)
|| !TryFindPoint(ref svgPath, relative, c, out point2))
{
return false;
}
_ = builder.MoveTo(point1).LineTo(point2);
break;
default:
return false;
}
if (previousOp == 0)
{
first = c;
}
previousOp = op;
}
value = builder.Build();
return true;
}
private static bool TryFindFlag(ref ReadOnlySpan str, out bool value)
{
str = TrimSeparator(str);
// https://www.w3.org/TR/SVG11/paths.html#PathDataBNF
// flag: "0" | "1"
// Adjacent flags are valid, so this consumes exactly one character.
if (str.Length == 0 || (str[0] is not '0' and not '1'))
{
value = default;
return false;
}
value = str[0] == '1';
str = str[1..];
return true;
}
private static bool TryTrimSeparator(ref ReadOnlySpan str)
{
// SVG separators are optional in places where the next token can be
// recognized unambiguously. Keep this chainable with the operand readers.
ReadOnlySpan result = TrimSeparator(str);
if (str[^result.Length..].StartsWith(result))
{
str = result;
return true;
}
return false;
}
private static bool TryFindScaler(ref ReadOnlySpan str, out float value)
{
ReadOnlySpan source = TrimSeparator(str);
if (TryReadScalar(source, out value, out int length))
{
str = source[length..];
return true;
}
value = default;
return false;
}
private static bool TryFindPoint(ref ReadOnlySpan str, bool relative, PointF current, out PointF value)
{
if (TryFindScaler(ref str, out float x) && TryFindScaler(ref str, out float y))
{
// Relative operands can overflow after adding the current point even when
// each parsed scalar is finite, so validate the absolute result as well.
if (relative)
{
x += current.X;
y += current.Y;
}
if (!float.IsFinite(x) || !float.IsFinite(y))
{
value = default;
return false;
}
value = new PointF(x, y);
return true;
}
value = default;
return false;
}
private static bool TryReadScalar(ReadOnlySpan str, out float scaler, out int length)
{
// SVG path numbers can be tightly packed: "10-20" is two numbers, as is
// "0.5.6". Stop at the first character that belongs to the next token.
bool hasDot = false;
for (int i = 0; i < str.Length; i++)
{
char ch = str[i];
if (IsSeparator(ch))
{
length = i;
return TryParseFloat(str[..length], out scaler);
}
if (ch == '.')
{
if (hasDot)
{
// Second decimal point starts a new number.
length = i;
return TryParseFloat(str[..length], out scaler);
}
hasDot = true;
}
else if ((ch is '-' or '+') && i > 0)
{
// A sign character mid-number starts a new number,
// unless it follows an exponent indicator.
char prev = str[i - 1];
if (prev is not 'e' and not 'E')
{
length = i;
return TryParseFloat(str[..length], out scaler);
}
}
else if (char.IsLetter(ch))
{
// Hit a command letter; end this number.
length = i;
return TryParseFloat(str[..length], out scaler);
}
}
length = str.Length;
return TryParseFloat(str, out scaler);
}
private static bool IsSeparator(char ch)
=> char.IsWhiteSpace(ch) || ch == ',';
private static ReadOnlySpan TrimSeparator(ReadOnlySpan data)
{
if (data.Length == 0)
{
return data;
}
int idx = 0;
for (; idx < data.Length; idx++)
{
if (!IsSeparator(data[idx]))
{
break;
}
}
return data[idx..];
}
private static bool TryParseFloat(ReadOnlySpan str, out float value)
=> float.TryParse(str, CultureInfo.InvariantCulture, out value) && float.IsFinite(value);
}
}