diff --git a/ImageSharp.Drawing.sln b/ImageSharp.Drawing.sln index 575d4a5ed..51c00414c 100644 --- a/ImageSharp.Drawing.sln +++ b/ImageSharp.Drawing.sln @@ -337,6 +337,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ .github\workflows\build-and-test.yml = .github\workflows\build-and-test.yml EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImageSharp.Drawing.ManualBenchmarks", "tests\ImageSharp.Drawing.ManualBenchmarks\ImageSharp.Drawing.ManualBenchmarks.csproj", "{EB9C10E0-59B7-4620-B8EB-220E8FFC73E6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -359,6 +361,10 @@ Global {5493F024-0A3F-420C-AC2D-05B77A36025B}.Debug|Any CPU.Build.0 = Debug|Any CPU {5493F024-0A3F-420C-AC2D-05B77A36025B}.Release|Any CPU.ActiveCfg = Release|Any CPU {5493F024-0A3F-420C-AC2D-05B77A36025B}.Release|Any CPU.Build.0 = Release|Any CPU + {EB9C10E0-59B7-4620-B8EB-220E8FFC73E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB9C10E0-59B7-4620-B8EB-220E8FFC73E6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB9C10E0-59B7-4620-B8EB-220E8FFC73E6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB9C10E0-59B7-4620-B8EB-220E8FFC73E6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -386,6 +392,7 @@ Global {68A8CC40-6AED-4E96-B524-31B1158FDEEA} = {815C0625-CD3D-440F-9F80-2D83856AB7AE} {5493F024-0A3F-420C-AC2D-05B77A36025B} = {528610AC-7C0C-46E8-9A2D-D46FD92FEE29} {23859314-5693-4E6C-BE5C-80A433439D2A} = {1799C43E-5C54-4A8F-8D64-B1475241DB0D} + {EB9C10E0-59B7-4620-B8EB-220E8FFC73E6} = {56801022-D71A-4FBE-BC5B-CBA08E2284EC} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5F8B9D1F-CD8B-4CC5-8216-D531E25BD795} diff --git a/src/ImageSharp.Drawing/Shapes/Path.cs b/src/ImageSharp.Drawing/Shapes/Path.cs index b7ab77d49..f4b222fa0 100644 --- a/src/ImageSharp.Drawing/Shapes/Path.cs +++ b/src/ImageSharp.Drawing/Shapes/Path.cs @@ -145,7 +145,7 @@ SegmentInfo IPathInternals.PointAlongPath(float distance) 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. @@ -381,10 +381,42 @@ private static ReadOnlySpan FindScaler(ReadOnlySpan str, out float s str = TrimSeparator(str); scaler = 0; + bool hasDot = false; for (int i = 0; i < str.Length; i++) { - if (IsSeparator(str[i]) || i == str.Length) + char ch = str[i]; + + if (IsSeparator(ch)) + { + scaler = ParseFloat(str[..i]); + return str[i..]; + } + + if (ch == '.') + { + if (hasDot) + { + // Second decimal point starts a new number. + scaler = ParseFloat(str[..i]); + return str[i..]; + } + + 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') + { + scaler = ParseFloat(str[..i]); + return str[i..]; + } + } + else if (char.IsLetter(ch)) { + // Hit a command letter; end this number. scaler = ParseFloat(str[..i]); return str[i..]; } @@ -395,7 +427,7 @@ private static ReadOnlySpan FindScaler(ReadOnlySpan str, out float s scaler = ParseFloat(str); } - return ReadOnlySpan.Empty; + return Array.Empty(); } private static bool IsSeparator(char ch) diff --git a/tests/ImageSharp.Drawing.Benchmarks/Drawing/FillTiger.cs b/tests/ImageSharp.Drawing.Benchmarks/Drawing/FillTiger.cs new file mode 100644 index 000000000..2d06bd171 --- /dev/null +++ b/tests/ImageSharp.Drawing.Benchmarks/Drawing/FillTiger.cs @@ -0,0 +1,150 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Drawing; +using System.Drawing.Drawing2D; +using BenchmarkDotNet.Attributes; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.Drawing.Tests; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using SkiaSharp; +using SDColor = System.Drawing.Color; +using SDPen = System.Drawing.Pen; +using SDSolidBrush = System.Drawing.SolidBrush; + +namespace SixLabors.ImageSharp.Drawing.Benchmarks.Drawing; + +/// +/// Benchmarks rendering the Ghostscript Tiger SVG (~240 path elements with fills and strokes) +/// across SkiaSharp, System.Drawing, ImageSharp (CPU), and ImageSharp (WebGPU). +/// +public class FillTiger +{ + private static readonly string SvgFilePath = + TestFile.GetInputFileFullPath(TestImages.Svg.GhostscriptTiger); + + private SKSurface skSurface; + private List<(SKPath Path, SKPaint FillPaint, SKPaint StrokePaint)> skElements; + + private Bitmap sdBitmap; + private Graphics sdGraphics; + private List<(GraphicsPath Path, SDSolidBrush Fill, SDPen Stroke)> sdElements; + + private Image image; + private List<(IPath Path, Processing.SolidBrush Fill, SolidPen Stroke)> isElements; + + [Params(1000, 100)] + public int Dimensions { get; set; } + + [GlobalSetup] + public void Setup() + { + int width = this.Dimensions; + int height = this.Dimensions; + float scale = this.Dimensions / 200f; + + ThreadPool.GetMinThreads(out int minWorkerThreads, out int minCompletionPortThreads); + int desiredWorkerThreads = Math.Max(minWorkerThreads, Environment.ProcessorCount); + ThreadPool.SetMinThreads(desiredWorkerThreads, minCompletionPortThreads); + Parallel.For(0, desiredWorkerThreads, static _ => { }); + + List elements = SvgBenchmarkHelper.ParseSvg(SvgFilePath); + + this.skSurface = SKSurface.Create(new SKImageInfo(width, height)); + this.skElements = SvgBenchmarkHelper.BuildSkiaElements(elements, scale); + + this.sdBitmap = new Bitmap(width, height); + this.sdGraphics = Graphics.FromImage(this.sdBitmap); + this.sdGraphics.SmoothingMode = SmoothingMode.AntiAlias; + this.sdElements = SvgBenchmarkHelper.BuildSystemDrawingElements(elements, scale); + + this.image = new Image(width, height); + this.isElements = SvgBenchmarkHelper.BuildImageSharpElements(elements, scale); + } + + [IterationSetup] + public void IterationSetup() + { + this.sdGraphics.Clear(SDColor.Transparent); + this.skSurface.Canvas.Clear(SKColors.Transparent); + } + + [GlobalCleanup] + public void Cleanup() + { + foreach ((SKPath path, SKPaint fill, SKPaint stroke) in this.skElements) + { + path.Dispose(); + fill?.Dispose(); + stroke?.Dispose(); + } + + this.skSurface.Dispose(); + + foreach ((GraphicsPath path, SDSolidBrush fill, SDPen stroke) in this.sdElements) + { + path.Dispose(); + fill?.Dispose(); + stroke?.Dispose(); + } + + this.sdGraphics.Dispose(); + this.sdBitmap.Dispose(); + + this.image.Dispose(); + } + + [Benchmark(Baseline = true)] + public void SkiaSharp() + { + SKCanvas canvas = this.skSurface.Canvas; + foreach ((SKPath path, SKPaint fillPaint, SKPaint strokePaint) in this.skElements) + { + if (fillPaint is not null) + { + canvas.DrawPath(path, fillPaint); + } + + if (strokePaint is not null) + { + canvas.DrawPath(path, strokePaint); + } + } + } + + [Benchmark] + public void SystemDrawing() + { + foreach ((GraphicsPath path, SDSolidBrush fill, SDPen stroke) in this.sdElements) + { + if (fill is not null) + { + this.sdGraphics.FillPath(fill, path); + } + + if (stroke is not null) + { + this.sdGraphics.DrawPath(stroke, path); + } + } + } + + [Benchmark] + public void ImageSharp() + => this.image.Mutate(c => + { + foreach ((IPath path, Processing.SolidBrush fill, SolidPen stroke) in this.isElements) + { + if (fill is not null) + { + c.Fill(fill, path); + } + + if (stroke is not null) + { + c.Draw(stroke, path); + } + } + }); +} diff --git a/tests/ImageSharp.Drawing.Benchmarks/ImageSharp.Drawing.Benchmarks.csproj b/tests/ImageSharp.Drawing.Benchmarks/ImageSharp.Drawing.Benchmarks.csproj index daef9a87a..2d6e62af1 100644 --- a/tests/ImageSharp.Drawing.Benchmarks/ImageSharp.Drawing.Benchmarks.csproj +++ b/tests/ImageSharp.Drawing.Benchmarks/ImageSharp.Drawing.Benchmarks.csproj @@ -6,6 +6,7 @@ false false false + latest @@ -20,7 +21,7 @@ - + @@ -45,6 +46,9 @@ TestEnvironment.cs + + SvgBenchmarkHelper.cs + diff --git a/tests/ImageSharp.Drawing.ManualBenchmarks/DrawingThroughputBenchmark.cs b/tests/ImageSharp.Drawing.ManualBenchmarks/DrawingThroughputBenchmark.cs new file mode 100644 index 000000000..7e0e83df8 --- /dev/null +++ b/tests/ImageSharp.Drawing.ManualBenchmarks/DrawingThroughputBenchmark.cs @@ -0,0 +1,204 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Diagnostics; +using CommandLine; +using CommandLine.Text; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.Drawing.Tests; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +public sealed class DrawingThroughputBenchmark +{ + private readonly CommandLineOptions options; + private readonly List<(IPath Path, SolidBrush Fill, SolidPen Stroke)> elements; + private ulong totalProcessedPixels; + + private DrawingThroughputBenchmark(CommandLineOptions options) + { + this.options = options; + List elements = SvgBenchmarkHelper.ParseSvg( + TestFile.GetInputFileFullPath(TestImages.Svg.GhostscriptTiger)); + float size = (options.Width + options.Height) * 0.5f; + this.elements = SvgBenchmarkHelper.BuildImageSharpElements(elements, size / 200f); + } + + public static Task RunAsync(string[] args) + { + CommandLineOptions? options = null; + if (args.Length > 0) + { + options = CommandLineOptions.Parse(args); + if (options == null) + { + return Task.CompletedTask; + } + } + + options ??= new CommandLineOptions(); + return new DrawingThroughputBenchmark(options.Normalize()) + .RunAsync(); + } + + private async Task RunAsync() + { + SemaphoreSlim semaphore = new(this.options.ConcurrentRequests); + Console.WriteLine(this.options.Method); + Func action = this.options.Method switch + { + Method.Tiger => this.Tiger, + _ => throw new NotImplementedException(), + }; + + Console.WriteLine(this.options); + Console.WriteLine($"Running {this.options.Method} for {this.options.Seconds} seconds ..."); + TimeSpan runFor = TimeSpan.FromSeconds(this.options.Seconds); + + // inFlight starts at 1 to represent the dispatch loop itself + int inFlight = 1; + TaskCompletionSource drainTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + + Stopwatch stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < runFor && !drainTcs.Task.IsCompleted) + { + await semaphore.WaitAsync(); + + if (stopwatch.Elapsed >= runFor) + { + semaphore.Release(); + break; + } + + Interlocked.Increment(ref inFlight); + + _ = ProcessImage(); + + async Task ProcessImage() + { + try + { + if (stopwatch.Elapsed >= runFor || drainTcs.Task.IsCompleted) + { + return; + } + + await Task.Yield(); // "emulate IO", i.e., make sure the processing code is async + ulong pixels = (ulong)action(); + Interlocked.Add(ref this.totalProcessedPixels, pixels); + } + catch (Exception ex) + { + Console.WriteLine(ex); + drainTcs.TrySetException(ex); + } + finally + { + semaphore.Release(); + if (Interlocked.Decrement(ref inFlight) == 0) + { + drainTcs.TrySetResult(); + } + } + } + } + + // Release the dispatch loop's own count; if no work is in flight, this completes immediately + if (Interlocked.Decrement(ref inFlight) == 0) + { + drainTcs.TrySetResult(); + } + + await drainTcs.Task; + stopwatch.Stop(); + + double totalMegaPixels = this.totalProcessedPixels / 1_000_000.0; + double totalSeconds = stopwatch.ElapsedMilliseconds / 1000.0; + double megapixelsPerSec = totalMegaPixels / totalSeconds; + Console.WriteLine($"TotalSeconds: {totalSeconds:F2}"); + Console.WriteLine($"MegaPixelsPerSec: {megapixelsPerSec:F2}"); + } + + private int Tiger() + { + using Image image = new(this.options.Width, this.options.Height); + image.Mutate(c => + { + foreach ((IPath path, SolidBrush fill, SolidPen stroke) in this.elements) + { + if (fill is not null) + { + c.Fill(fill, path); + } + + if (stroke is not null) + { + c.Draw(stroke, path); + } + } + }); + return image.Width * image.Height; + } + + private enum Method + { + Tiger, + } + + private sealed class CommandLineOptions + { + private const int DefaultSize = 2000; + + [Option('m', "method", Required = false, Default = Method.Tiger, HelpText = "The stress test method to run (Edges, Crop)")] + public Method Method { get; set; } = Method.Tiger; + + [Option('c', "concurrent-requests", Required = false, Default = -1, HelpText = "Number of concurrent in-flight requests")] + public int ConcurrentRequests { get; set; } = -1; + + [Option('w', "width", Required = false, Default = DefaultSize, HelpText = "Width of the test image")] + public int Width { get; set; } = DefaultSize; + + [Option('h', "height", Required = false, Default = DefaultSize, HelpText = "Height of the test image")] + public int Height { get; set; } = DefaultSize; + + [Option('s', "seconds", Required = false, Default = 5, HelpText = "Duration of the stress test in seconds")] + public int Seconds { get; set; } = 5; + + public override string ToString() => string.Join( + "|", + $"method: {this.Method}", + $"concurrent-requests: {this.ConcurrentRequests}", + $"width: {this.Width}", + $"height: {this.Height}", + $"seconds: {this.Seconds}"); + + public CommandLineOptions Normalize() + { + if (this.ConcurrentRequests < 0) + { + this.ConcurrentRequests = Environment.ProcessorCount; + } + + return this; + } + + public static CommandLineOptions? Parse(string[] args) + { + CommandLineOptions? result = null; + using Parser parser = new(settings => settings.CaseInsensitiveEnumValues = true); + ParserResult parserResult = parser.ParseArguments(args).WithParsed(o => + { + result = o; + }); + + if (result == null) + { + Console.WriteLine(HelpText.RenderUsageText(parserResult)); + } + + return result; + } + } +} diff --git a/tests/ImageSharp.Drawing.ManualBenchmarks/ImageSharp.Drawing.ManualBenchmarks.csproj b/tests/ImageSharp.Drawing.ManualBenchmarks/ImageSharp.Drawing.ManualBenchmarks.csproj new file mode 100644 index 000000000..8e8cb5ea5 --- /dev/null +++ b/tests/ImageSharp.Drawing.ManualBenchmarks/ImageSharp.Drawing.ManualBenchmarks.csproj @@ -0,0 +1,56 @@ + + + + Exe + enable + enable + SixLabors.ImageSharp.Drawing.ManualBenchmarks + false + false + latest + + + + + + net7.0;net6.0 + + + + + net6.0 + + + + + + + + + + + + + + + + + + + TestFile.cs + + + TestImages.cs + + + PolygonFactory.cs + + + TestEnvironment.cs + + + SvgBenchmarkHelper.cs + + + + diff --git a/tests/ImageSharp.Drawing.ManualBenchmarks/Program.cs b/tests/ImageSharp.Drawing.ManualBenchmarks/Program.cs new file mode 100644 index 000000000..981ded1bf --- /dev/null +++ b/tests/ImageSharp.Drawing.ManualBenchmarks/Program.cs @@ -0,0 +1,4 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +await DrawingThroughputBenchmark.RunAsync(args); diff --git a/tests/ImageSharp.Drawing.Tests/Drawing/SvgTests.cs b/tests/ImageSharp.Drawing.Tests/Drawing/SvgTests.cs new file mode 100644 index 000000000..20571cab4 --- /dev/null +++ b/tests/ImageSharp.Drawing.Tests/Drawing/SvgTests.cs @@ -0,0 +1,38 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace SixLabors.ImageSharp.Drawing.Tests.Drawing; + +public class SvgTests +{ + [Theory] + [WithBlankImage(200, 200, PixelTypes.Rgba32, 1f)] + [WithBlankImage(1000, 1000, PixelTypes.Rgba32, 5f)] + public void Tiger(TestImageProvider provider, float scale) + where TPixel : unmanaged, IPixel + { + List<(IPath Path, SolidBrush Fill, SolidPen Stroke)> elements = SvgBenchmarkHelper.BuildImageSharpElements( + SvgBenchmarkHelper.ParseSvg(TestFile.GetInputFileFullPath(TestImages.Svg.GhostscriptTiger)), scale); + Image image = provider.GetImage(); + image.Mutate(c => + { + foreach ((IPath path, SolidBrush fill, SolidPen stroke) in elements) + { + if (fill is not null) + { + c.Fill(fill, path); + } + + if (stroke is not null) + { + c.Draw(stroke, path); + } + } + }); + image.DebugSave(provider, $"s{scale}"); + } +} diff --git a/tests/ImageSharp.Drawing.Tests/TestImages.cs b/tests/ImageSharp.Drawing.Tests/TestImages.cs index 0afbeedec..b19cb83ca 100644 --- a/tests/ImageSharp.Drawing.Tests/TestImages.cs +++ b/tests/ImageSharp.Drawing.Tests/TestImages.cs @@ -394,4 +394,10 @@ public static class GeoJson { public const string States = "GeoJson/States.json"; } + + public static class Svg + { + public const string GhostscriptTiger = "Svg/Ghostscript_Tiger.svg"; + public const string Paris30k = "Svg/paris-30k.svg"; + } } diff --git a/tests/ImageSharp.Drawing.Tests/TestUtilities/PolygonFactory.cs b/tests/ImageSharp.Drawing.Tests/TestUtilities/PolygonFactory.cs index 4d371b706..7e6c97c2b 100644 --- a/tests/ImageSharp.Drawing.Tests/TestUtilities/PolygonFactory.cs +++ b/tests/ImageSharp.Drawing.Tests/TestUtilities/PolygonFactory.cs @@ -74,11 +74,7 @@ public static PointF[][] GetGeoJsonPoints(string geoJsonContent) => GetGeoJsonPoints(geoJsonContent, Matrix3x2.Identity); public static Polygon CreatePolygon(params (float X, float Y)[] coords) - => new(new LinearLineSegment(CreatePointArray(coords))) - { - // The default epsilon is too large for test code, we prefer the vertices not to be changed - RemoveCloseAndCollinearPoints = false - }; + => new(new LinearLineSegment(CreatePointArray(coords))); public static (PointF Start, PointF End) CreateHorizontalLine(float y) => (new PointF(-Inf, y), new PointF(Inf, y)); diff --git a/tests/ImageSharp.Drawing.Tests/TestUtilities/SvgBenchmarkHelper.cs b/tests/ImageSharp.Drawing.Tests/TestUtilities/SvgBenchmarkHelper.cs new file mode 100644 index 000000000..60a58f987 --- /dev/null +++ b/tests/ImageSharp.Drawing.Tests/TestUtilities/SvgBenchmarkHelper.cs @@ -0,0 +1,1084 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Globalization; +using System.Numerics; +using System.Xml.Linq; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SkiaSharp; +using ISColor = SixLabors.ImageSharp.Color; +using ISDrawingProcessing = SixLabors.ImageSharp.Drawing.Processing; +using SDColor = System.Drawing.Color; +using SDPen = System.Drawing.Pen; +using SDSolidBrush = System.Drawing.SolidBrush; + +namespace SixLabors.ImageSharp.Drawing.Tests; + +/// +/// Shared SVG parsing and per-backend setup for SVG rendering benchmarks. +/// +internal static class SvgBenchmarkHelper +{ + private const float NeighborhoodPadding = 12F; + + /// + /// A single parsed SVG path element with fill, stroke, and per-element transform. + /// + internal readonly record struct SvgElement( + string PathData, + ISColor Fill, + ISColor Stroke, + float StrokeWidth, + Matrix3x2? Transform); + + /// + /// Parses an SVG file into a list of s. + /// Handles fill, fill-opacity, stroke, stroke-width, opacity, and per-path transform attributes. + /// Group-level transforms are composed with per-path transforms. + /// + internal static List ParseSvg(string filePath) + { + XDocument doc = XDocument.Load(filePath); + XNamespace ns = "http://www.w3.org/2000/svg"; + List result = []; + + // Collect group transforms into a stack. + // For simplicity, iterate all path elements and walk up to resolve inherited transforms. + foreach (XElement pathEl in doc.Descendants(ns + "path")) + { + string d = pathEl.Attribute("d")?.Value; + if (string.IsNullOrWhiteSpace(d)) + { + continue; + } + + // Parse fill color (default black per SVG spec). + string fillStr = ResolveInheritedPresentationValue(pathEl, "fill"); + ISColor fill; + if (fillStr is null) + { + fill = ISColor.Black; + } + else if (fillStr == "none") + { + fill = ISColor.Transparent; + } + else if (ISColor.TryParse(fillStr, out ISColor parsed)) + { + fill = parsed; + } + else + { + fill = ISColor.Black; + } + + // Apply fill-opacity. + if (float.TryParse( + ResolveInheritedPresentationValue(pathEl, "fill-opacity"), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out float fillOpacity)) + { + Rgba32 fp = fill.ToPixel(); + fp.A = (byte)(fp.A * Math.Clamp(fillOpacity, 0f, 1f)); + fill = ISColor.FromPixel(fp); + } + + // Apply element-level opacity to fill alpha. + if (float.TryParse( + pathEl.Attribute("opacity")?.Value, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out float opacity)) + { + Rgba32 fp = fill.ToPixel(); + fp.A = (byte)(fp.A * Math.Clamp(opacity, 0f, 1f)); + fill = ISColor.FromPixel(fp); + } + + // Parse stroke. + ISColor stroke = ParseColor(ResolveInheritedPresentationValue(pathEl, "stroke")); + float strokeWidth = float.TryParse( + ResolveInheritedPresentationValue(pathEl, "stroke-width"), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out float sw) ? sw : 0f; + + // Apply element-level opacity to stroke alpha. + if (opacity > 0 && opacity < 1) + { + Rgba32 sp = stroke.ToPixel(); + sp.A = (byte)(sp.A * Math.Clamp(opacity, 0f, 1f)); + stroke = ISColor.FromPixel(sp); + } + + // Resolve transform: compose per-path transform with ancestor group transforms. + Matrix3x2? transform = ResolveTransform(pathEl, ns); + + result.Add(new SvgElement(d, fill, stroke, strokeWidth, transform)); + } + + return result; + } + + /// + /// Builds pre-parsed SkiaSharp elements for benchmarking. + /// + internal static List<(SKPath Path, SKPaint FillPaint, SKPaint StrokePaint)> BuildSkiaElements( + List elements, + float scale) + { + List<(SKPath, SKPaint, SKPaint)> result = []; + foreach (SvgElement el in elements) + { + SKPath skPath = SKPath.ParseSvgPathData(el.PathData); + if (skPath is null) + { + continue; + } + + SKMatrix skMatrix = SKMatrix.CreateScale(scale, scale); + if (el.Transform.HasValue) + { + Matrix3x2 m = el.Transform.Value; + SKMatrix elMatrix = new(m.M11, m.M21, m.M31, m.M12, m.M22, m.M32, 0, 0, 1); + skMatrix = SKMatrix.Concat(skMatrix, elMatrix); + } + + skPath.Transform(skMatrix); + + Rgba32 fillPixel = el.Fill.ToPixel(); + SKPaint fillPaint = fillPixel.A > 0 + ? new SKPaint + { + Style = SKPaintStyle.Fill, + Color = new SKColor(fillPixel.R, fillPixel.G, fillPixel.B, fillPixel.A), + IsAntialias = true, + } + : null; + + Rgba32 strokePixel = el.Stroke.ToPixel(); + SKPaint strokePaint = strokePixel.A > 0 && el.StrokeWidth > 0 + ? new SKPaint + { + Style = SKPaintStyle.Stroke, + Color = new SKColor(strokePixel.R, strokePixel.G, strokePixel.B, strokePixel.A), + StrokeWidth = el.StrokeWidth * scale, + IsAntialias = true, + } + : null; + + result.Add((skPath, fillPaint, strokePaint)); + } + + return result; + } + + /// + /// Builds pre-parsed System.Drawing elements for benchmarking. + /// + internal static List<(GraphicsPath Path, SDSolidBrush Fill, SDPen Stroke)> BuildSystemDrawingElements( + List elements, + float scale) + { + List<(GraphicsPath, SDSolidBrush, SDPen)> result = []; + foreach (SvgElement el in elements) + { + GraphicsPath sdPath = SvgPathDataToGraphicsPath(el.PathData, scale, el.Transform); + + Rgba32 fillPixel = el.Fill.ToPixel(); + SDSolidBrush fill = fillPixel.A > 0 + ? new SDSolidBrush(SDColor.FromArgb(fillPixel.A, fillPixel.R, fillPixel.G, fillPixel.B)) + : null; + + Rgba32 strokePixel = el.Stroke.ToPixel(); + SDPen stroke = strokePixel.A > 0 && el.StrokeWidth > 0 + ? new SDPen(SDColor.FromArgb(strokePixel.A, strokePixel.R, strokePixel.G, strokePixel.B), el.StrokeWidth * scale) + : null; + + result.Add((sdPath, fill, stroke)); + } + + return result; + } + + /// + /// Builds pre-parsed ImageSharp elements for benchmarking. + /// + internal static List<(IPath Path, ISDrawingProcessing.SolidBrush Fill, SolidPen Stroke)> BuildImageSharpElements( + List elements, + float scale) + { + List<(IPath, ISDrawingProcessing.SolidBrush, SolidPen)> result = []; + foreach (SvgElement el in elements) + { + if (!Path.TryParseSvgPath(el.PathData, out IPath isPath)) + { + continue; + } + + Matrix3x2 scaleMatrix = Matrix3x2.CreateScale(scale); + if (el.Transform.HasValue) + { + isPath = isPath.Transform(el.Transform.Value * scaleMatrix); + } + else + { + isPath = isPath.Transform(scaleMatrix); + } + + Rgba32 fillPixel = el.Fill.ToPixel(); + ISDrawingProcessing.SolidBrush fill = fillPixel.A > 0 + ? new ISDrawingProcessing.SolidBrush(el.Fill) + : null; + + Rgba32 strokePixel = el.Stroke.ToPixel(); + SolidPen stroke = strokePixel.A > 0 && el.StrokeWidth > 0 + ? new SolidPen(el.Stroke, el.StrokeWidth * scale) + : null; + + result.Add((isPath, fill, stroke)); + } + + return result; + } + + /// + /// Writes a spatial neighborhood SVG for the requested path using the parsed SVG elements. + /// + internal static void WriteNeighborhoodSvg( + string name, + IReadOnlyList elements, + string targetPathData, + int width, + int height) + { + string outDir = System.IO.Path.Combine(AppContext.BaseDirectory, $"{name}-verify"); + Directory.CreateDirectory(outDir); + + List<(SvgElement Element, RectangleF Bounds)> candidates = []; + (SvgElement Element, RectangleF Bounds)? target = null; + + foreach (SvgElement element in elements) + { + if (!TryGetTransformedBounds(element, out RectangleF bounds)) + { + continue; + } + + candidates.Add((element, bounds)); + + if (target is null && string.Equals(element.PathData, targetPathData, StringComparison.Ordinal)) + { + target = (element, bounds); + } + } + + if (target is null) + { + return; + } + + RectangleF viewport = Inflate(target.Value.Bounds, NeighborhoodPadding); + List neighborhood = []; + foreach ((SvgElement element, RectangleF bounds) in candidates) + { + if (bounds.IntersectsWith(viewport)) + { + neighborhood.Add(element); + viewport = RectangleF.Union(viewport, bounds); + } + } + + viewport = RectangleF.Intersect(Inflate(viewport, NeighborhoodPadding), new RectangleF(0, 0, width, height)); + + XNamespace ns = "http://www.w3.org/2000/svg"; + XElement svg = new( + ns + "svg", + new XAttribute("xmlns", ns.NamespaceName), + new XAttribute("viewBox", FormattableString.Invariant($"{viewport.X} {viewport.Y} {viewport.Width} {viewport.Height}")), + new XAttribute("width", FormattableString.Invariant($"{viewport.Width}")), + new XAttribute("height", FormattableString.Invariant($"{viewport.Height}"))); + + foreach (SvgElement element in neighborhood) + { + svg.Add(CreatePathElement(ns, element)); + } + + XDocument document = new(new XDeclaration("1.0", "utf-8", null), svg); + document.Save(System.IO.Path.Combine(outDir, $"{name}-neighborhood.svg")); + } + + // ---- SVG transform resolution ---- + + private static Matrix3x2? ResolveTransform(XElement element, XNamespace ns) + { + // Walk up the tree, collecting transforms from path → root. + List transforms = null; + XElement current = element; + while (current is not null) + { + string transformStr = current.Attribute("transform")?.Value; + if (transformStr is not null && TryParseTransform(transformStr, out Matrix3x2 m)) + { + transforms ??= []; + transforms.Add(m); + } + + current = current.Parent; + } + + if (transforms is null) + { + return null; + } + + // Compose from root → leaf (reverse of collection order). + Matrix3x2 result = Matrix3x2.Identity; + for (int i = transforms.Count - 1; i >= 0; i--) + { + result *= transforms[i]; + } + + return result; + } + + private static bool TryParseTransform(string value, out Matrix3x2 result) + { + result = Matrix3x2.Identity; + ReadOnlySpan span = value.AsSpan().Trim(); + + if (span.StartsWith("matrix(") && span.EndsWith(")")) + { + span = span[7..^1]; + Span values = stackalloc float[6]; + for (int i = 0; i < 6; i++) + { + span = span.TrimStart(); + if (span.Length > 0 && span[0] == ',') + { + span = span[1..].TrimStart(); + } + + int end = 0; + if (end < span.Length && (span[end] is '-' or '+')) + { + end++; + } + + bool hasDot = false; + while (end < span.Length) + { + char c = span[end]; + if (c == '.' && !hasDot) + { + hasDot = true; + end++; + } + else if (char.IsDigit(c)) + { + end++; + } + else if (c is 'e' or 'E') + { + end++; + if (end < span.Length && span[end] is '+' or '-') + { + end++; + } + + while (end < span.Length && char.IsDigit(span[end])) + { + end++; + } + + break; + } + else + { + break; + } + } + + if (end == 0) + { + return false; + } + + values[i] = float.Parse(span[..end], NumberStyles.Any, CultureInfo.InvariantCulture); + span = span[end..]; + } + + // SVG matrix(a,b,c,d,e,f) maps to: + // | a c e | M11=a M21=c M31=e + // | b d f | → M12=b M22=d M32=f + // | 0 0 1 | rest = identity + result = new Matrix3x2( + values[0], values[1], + values[2], values[3], + values[4], values[5]); + return true; + } + + if (span.StartsWith("translate(") && span.EndsWith(")")) + { + span = span[10..^1]; + ReadOnlySpan trimmed = span.Trim(); + int sep = trimmed.IndexOfAny(',', ' '); + float tx = float.Parse(sep < 0 ? trimmed : trimmed[..sep], NumberStyles.Any, CultureInfo.InvariantCulture); + float ty = sep < 0 ? 0 : float.Parse(trimmed[(sep + 1)..].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture); + result = Matrix3x2.CreateTranslation(tx, ty); + return true; + } + + if (span.StartsWith("scale(") && span.EndsWith(")")) + { + span = span[6..^1]; + ReadOnlySpan trimmed = span.Trim(); + int sep = trimmed.IndexOfAny(',', ' '); + float sx = float.Parse(sep < 0 ? trimmed : trimmed[..sep], NumberStyles.Any, CultureInfo.InvariantCulture); + float sy = sep < 0 ? sx : float.Parse(trimmed[(sep + 1)..].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture); + result = Matrix3x2.CreateScale(sx, sy); + return true; + } + + return false; + } + + private static ISColor ParseColor(string value) + { + if (string.IsNullOrWhiteSpace(value) || value == "none") + { + return ISColor.Transparent; + } + + return ISColor.TryParse(value, out ISColor color) ? color : ISColor.Transparent; + } + + private static string ResolveInheritedPresentationValue(XElement element, string attributeName) + { + for (XElement current = element; current is not null; current = current.Parent) + { + if (TryGetPresentationValue(current, attributeName, out string value)) + { + return value; + } + } + + return null; + } + + private static bool TryGetPresentationValue(XElement element, string attributeName, out string value) + { + XAttribute attribute = element.Attribute(attributeName); + if (attribute is not null) + { + value = attribute.Value; + return true; + } + + string style = element.Attribute("style")?.Value; + if (TryGetStyleValue(style, attributeName, out value)) + { + return true; + } + + value = null; + return false; + } + + private static bool TryGetStyleValue(string style, string attributeName, out string value) + { + if (string.IsNullOrWhiteSpace(style)) + { + value = null; + return false; + } + + foreach (string entry in style.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + int separatorIndex = entry.IndexOf(':'); + if (separatorIndex <= 0 || separatorIndex >= entry.Length - 1) + { + continue; + } + + if (!entry[..separatorIndex].Equals(attributeName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + value = entry[(separatorIndex + 1)..].Trim(); + return true; + } + + value = null; + return false; + } + + private static XElement CreatePathElement(XNamespace ns, SvgElement element) + { + XElement path = new( + ns + "path", + new XAttribute("d", element.PathData)); + + Rgba32 fillPixel = element.Fill.ToPixel(); + if (fillPixel.A == 0) + { + path.SetAttributeValue("fill", "none"); + } + else + { + path.SetAttributeValue("fill", ToSvgColor(fillPixel)); + if (fillPixel.A < byte.MaxValue) + { + path.SetAttributeValue("fill-opacity", FormattableString.Invariant($"{fillPixel.A / 255F:0.######}")); + } + } + + Rgba32 strokePixel = element.Stroke.ToPixel(); + if (strokePixel.A == 0 || element.StrokeWidth <= 0) + { + path.SetAttributeValue("stroke", "none"); + } + else + { + path.SetAttributeValue("stroke", ToSvgColor(strokePixel)); + path.SetAttributeValue("stroke-width", FormattableString.Invariant($"{element.StrokeWidth:0.######}")); + if (strokePixel.A < byte.MaxValue) + { + path.SetAttributeValue("stroke-opacity", FormattableString.Invariant($"{strokePixel.A / 255F:0.######}")); + } + } + + if (element.Transform is Matrix3x2 transform) + { + path.SetAttributeValue( + "transform", + FormattableString.Invariant( + $"matrix({transform.M11:0.######} {transform.M12:0.######} {transform.M21:0.######} {transform.M22:0.######} {transform.M31:0.######} {transform.M32:0.######})")); + } + + return path; + } + + private static RectangleF Inflate(RectangleF rectangle, float amount) => + new( + rectangle.X - amount, + rectangle.Y - amount, + rectangle.Width + (amount * 2), + rectangle.Height + (amount * 2)); + + private static string ToSvgColor(Rgba32 pixel) => + FormattableString.Invariant($"#{pixel.R:x2}{pixel.G:x2}{pixel.B:x2}"); + + private static bool TryGetTransformedBounds(SvgElement element, out RectangleF bounds) + { + bounds = default; + if (!Path.TryParseSvgPath(element.PathData, out IPath path)) + { + return false; + } + + if (element.Transform is Matrix3x2 transform) + { + path = path.Transform(transform); + } + + bounds = path.Bounds; + return true; + } + + // ---- System.Drawing SVG path parser ---- + internal static GraphicsPath SvgPathDataToGraphicsPath(string pathData, float scale, Matrix3x2? elementTransform) + { + GraphicsPath gp = new(FillMode.Winding); + float cx = 0, cy = 0; + float sx = 0, sy = 0; + float lcx = 0, lcy = 0; + + ReadOnlySpan span = pathData.AsSpan().Trim(); + char lastCmd = '\0'; + + while (span.Length > 0) + { + span = span.TrimStart(); + if (span.Length == 0) + { + break; + } + + char ch = span[0]; + char cmd; + if (char.IsLetter(ch)) + { + cmd = ch; + span = span[1..].TrimStart(); + lastCmd = cmd; + } + else + { + cmd = lastCmd; + } + + bool rel = char.IsLower(cmd); + char op = char.ToUpperInvariant(cmd); + + switch (op) + { + case 'M': + { + float x = ReadFloat(ref span); + TrimComma(ref span); + float y = ReadFloat(ref span); + if (rel) + { + x += cx; + y += cy; + } + + gp.StartFigure(); + cx = x; + cy = y; + sx = cx; + sy = cy; + lcx = cx; + lcy = cy; + lastCmd = rel ? 'l' : 'L'; + break; + } + + case 'L': + { + float x = ReadFloat(ref span); + TrimComma(ref span); + float y = ReadFloat(ref span); + if (rel) + { + x += cx; + y += cy; + } + + gp.AddLine(cx * scale, cy * scale, x * scale, y * scale); + cx = x; + cy = y; + lcx = cx; + lcy = cy; + break; + } + + case 'H': + { + float x = ReadFloat(ref span); + if (rel) + { + x += cx; + } + + gp.AddLine(cx * scale, cy * scale, x * scale, cy * scale); + cx = x; + lcx = cx; + lcy = cy; + break; + } + + case 'V': + { + float y = ReadFloat(ref span); + if (rel) + { + y += cy; + } + + gp.AddLine(cx * scale, cy * scale, cx * scale, y * scale); + cy = y; + lcx = cx; + lcy = cy; + break; + } + + case 'C': + { + float x1 = ReadFloat(ref span); + TrimComma(ref span); + float y1 = ReadFloat(ref span); + TrimComma(ref span); + float x2 = ReadFloat(ref span); + TrimComma(ref span); + float y2 = ReadFloat(ref span); + TrimComma(ref span); + float x = ReadFloat(ref span); + TrimComma(ref span); + float y = ReadFloat(ref span); + if (rel) + { + x1 += cx; + y1 += cy; + x2 += cx; + y2 += cy; + x += cx; + y += cy; + } + + gp.AddBezier( + cx * scale, cy * scale, + x1 * scale, y1 * scale, + x2 * scale, y2 * scale, + x * scale, y * scale); + lcx = x2; + lcy = y2; + cx = x; + cy = y; + break; + } + + case 'S': + { + float x2 = ReadFloat(ref span); + TrimComma(ref span); + float y2 = ReadFloat(ref span); + TrimComma(ref span); + float x = ReadFloat(ref span); + TrimComma(ref span); + float y = ReadFloat(ref span); + if (rel) + { + x2 += cx; + y2 += cy; + x += cx; + y += cy; + } + + float x1 = (2 * cx) - lcx; + float y1 = (2 * cy) - lcy; + + gp.AddBezier( + cx * scale, cy * scale, + x1 * scale, y1 * scale, + x2 * scale, y2 * scale, + x * scale, y * scale); + lcx = x2; + lcy = y2; + cx = x; + cy = y; + break; + } + + case 'Q': + { + float qx1 = ReadFloat(ref span); + TrimComma(ref span); + float qy1 = ReadFloat(ref span); + TrimComma(ref span); + float x = ReadFloat(ref span); + TrimComma(ref span); + float y = ReadFloat(ref span); + if (rel) + { + qx1 += cx; + qy1 += cy; + x += cx; + y += cy; + } + + float cx1 = cx + (2f / 3f * (qx1 - cx)); + float cy1 = cy + (2f / 3f * (qy1 - cy)); + float cx2 = x + (2f / 3f * (qx1 - x)); + float cy2 = y + (2f / 3f * (qy1 - y)); + + gp.AddBezier( + cx * scale, cy * scale, + cx1 * scale, cy1 * scale, + cx2 * scale, cy2 * scale, + x * scale, y * scale); + lcx = qx1; + lcy = qy1; + cx = x; + cy = y; + break; + } + + case 'T': + { + float x = ReadFloat(ref span); + TrimComma(ref span); + float y = ReadFloat(ref span); + if (rel) + { + x += cx; + y += cy; + } + + float qx1 = (2 * cx) - lcx; + float qy1 = (2 * cy) - lcy; + + float cx1 = cx + (2f / 3f * (qx1 - cx)); + float cy1 = cy + (2f / 3f * (qy1 - cy)); + float cx2 = x + (2f / 3f * (qx1 - x)); + float cy2 = y + (2f / 3f * (qy1 - y)); + + gp.AddBezier( + cx * scale, cy * scale, + cx1 * scale, cy1 * scale, + cx2 * scale, cy2 * scale, + x * scale, y * scale); + lcx = qx1; + lcy = qy1; + cx = x; + cy = y; + break; + } + + case 'A': + { + float rx = ReadFloat(ref span); + TrimComma(ref span); + float ry = ReadFloat(ref span); + TrimComma(ref span); + float xRotation = ReadFloat(ref span); + TrimComma(ref span); + float largeArcFlag = ReadFloat(ref span); + TrimComma(ref span); + float sweepFlag = ReadFloat(ref span); + TrimComma(ref span); + float x = ReadFloat(ref span); + TrimComma(ref span); + float y = ReadFloat(ref span); + if (rel) + { + x += cx; + y += cy; + } + + AddSvgArc(gp, cx, cy, rx, ry, xRotation, largeArcFlag != 0, sweepFlag != 0, x, y, scale); + cx = x; + cy = y; + lcx = cx; + lcy = cy; + break; + } + + case 'Z': + { + gp.CloseFigure(); + cx = sx; + cy = sy; + lcx = cx; + lcy = cy; + break; + } + + default: + if (span.Length > 0) + { + span = span[1..]; + } + + break; + } + + TrimComma(ref span); + } + + // Apply per-element transform via System.Drawing matrix. + if (elementTransform.HasValue) + { + Matrix3x2 m = elementTransform.Value; + using Matrix sdMatrix = new(m.M11, m.M12, m.M21, m.M22, m.M31 * scale, m.M32 * scale); + gp.Transform(sdMatrix); + } + + return gp; + } + + private static float ReadFloat(ref ReadOnlySpan span) + { + span = span.TrimStart(); + int len = 0; + if (len < span.Length && span[len] is '-' or '+') + { + len++; + } + + bool hasDot = false; + while (len < span.Length) + { + char c = span[len]; + if (c == '.' && !hasDot) + { + hasDot = true; + len++; + } + else if (char.IsDigit(c)) + { + len++; + } + else if (c is 'e' or 'E') + { + len++; + if (len < span.Length && span[len] is '+' or '-') + { + len++; + } + + while (len < span.Length && char.IsDigit(span[len])) + { + len++; + } + + break; + } + else + { + break; + } + } + + float result = float.Parse(span[..len], NumberStyles.Float, CultureInfo.InvariantCulture); + span = span[len..]; + return result; + } + + private static void TrimComma(ref ReadOnlySpan span) + { + span = span.TrimStart(); + if (span.Length > 0 && span[0] == ',') + { + span = span[1..].TrimStart(); + } + } + + private static void AddSvgArc( + GraphicsPath gp, + float x1, + float y1, + float rx, + float ry, + float xRotationDeg, + bool largeArc, + bool sweep, + float x2, + float y2, + float scale) + { + float dx = x2 - x1; + float dy = y2 - y1; + if ((dx * dx) + (dy * dy) < 1e-10f) + { + return; + } + + rx = MathF.Abs(rx); + ry = MathF.Abs(ry); + if (rx < 1e-5f || ry < 1e-5f) + { + gp.AddLine(x1 * scale, y1 * scale, x2 * scale, y2 * scale); + return; + } + + float xRot = xRotationDeg * MathF.PI / 180f; + float cosR = MathF.Cos(xRot); + float sinR = MathF.Sin(xRot); + + float dx2 = (x1 - x2) / 2f; + float dy2 = (y1 - y2) / 2f; + float x1p = (cosR * dx2) + (sinR * dy2); + float y1p = (-sinR * dx2) + (cosR * dy2); + + float rxSq = rx * rx; + float rySq = ry * ry; + float x1pSq = x1p * x1p; + float y1pSq = y1p * y1p; + + float cr = (x1pSq / rxSq) + (y1pSq / rySq); + if (cr > 1) + { + float s = MathF.Sqrt(cr); + rx *= s; + ry *= s; + rxSq = rx * rx; + rySq = ry * ry; + } + + float dq = (rxSq * y1pSq) + (rySq * x1pSq); + float pq = MathF.Max(0, ((rxSq * rySq) - dq) / dq); + float q = MathF.Sqrt(pq); + if (largeArc == sweep) + { + q = -q; + } + + float cxp = q * rx * y1p / ry; + float cyp = -q * ry * x1p / rx; + + float arcCx = (cosR * cxp) - (sinR * cyp) + ((x1 + x2) / 2f); + float arcCy = (sinR * cxp) + (cosR * cyp) + ((y1 + y2) / 2f); + + float theta = SvgAngle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry); + float delta = SvgAngle((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry); + delta %= MathF.PI * 2; + + if (!sweep && delta > 0) + { + delta -= 2 * MathF.PI; + } + + if (sweep && delta < 0) + { + delta += 2 * MathF.PI; + } + + float t = theta; + float remain = MathF.Abs(delta); + float sign = delta < 0 ? -1f : 1f; + float prevX = x1, prevY = y1; + + while (remain > 1e-5f) + { + float step = MathF.Min(remain, MathF.PI / 4f); + float signStep = step * sign; + float alphaT = MathF.Tan(signStep / 2f); + float alpha = MathF.Sin(signStep) * (MathF.Sqrt(4f + (3f * alphaT * alphaT)) - 1f) / 3f; + + float p2x = arcCx + (rx * MathF.Cos(xRot) * MathF.Cos(t + signStep)) - (ry * MathF.Sin(xRot) * MathF.Sin(t + signStep)); + float p2y = arcCy + (rx * MathF.Sin(xRot) * MathF.Cos(t + signStep)) + (ry * MathF.Cos(xRot) * MathF.Sin(t + signStep)); + + float d1x = (-rx * MathF.Cos(xRot) * MathF.Sin(t)) - (ry * MathF.Sin(xRot) * MathF.Cos(t)); + float d1y = (-rx * MathF.Sin(xRot) * MathF.Sin(t)) + (ry * MathF.Cos(xRot) * MathF.Cos(t)); + float d2x = (-rx * MathF.Cos(xRot) * MathF.Sin(t + signStep)) - (ry * MathF.Sin(xRot) * MathF.Cos(t + signStep)); + float d2y = (-rx * MathF.Sin(xRot) * MathF.Sin(t + signStep)) + (ry * MathF.Cos(xRot) * MathF.Cos(t + signStep)); + + float cp1x = prevX + (alpha * d1x); + float cp1y = prevY + (alpha * d1y); + float cp2x = p2x - (alpha * d2x); + float cp2y = p2y - (alpha * d2y); + + gp.AddBezier( + prevX * scale, prevY * scale, + cp1x * scale, cp1y * scale, + cp2x * scale, cp2y * scale, + p2x * scale, p2y * scale); + + prevX = p2x; + prevY = p2y; + t += signStep; + remain -= step; + } + } + + private static float SvgAngle(float ux, float uy, float vx, float vy) + { + float dot = (ux * vx) + (uy * vy); + float len = MathF.Sqrt(((ux * ux) + (uy * uy)) * ((vx * vx) + (vy * vy))); + float ang = MathF.Acos(Math.Clamp(dot / len, -1f, 1f)); + if ((ux * vy) - (uy * vx) < 0) + { + ang = -ang; + } + + return ang; + } +} diff --git a/tests/Images/Input/Svg/Ghostscript_Tiger.svg b/tests/Images/Input/Svg/Ghostscript_Tiger.svg new file mode 100644 index 000000000..033611d91 --- /dev/null +++ b/tests/Images/Input/Svg/Ghostscript_Tiger.svg @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Images/Input/Svg/paris-30k.svg b/tests/Images/Input/Svg/paris-30k.svg new file mode 100644 index 000000000..82b317017 --- /dev/null +++ b/tests/Images/Input/Svg/paris-30k.svg @@ -0,0 +1 @@ + \ No newline at end of file