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